GitHub Advisoryadvisory · npm
GHSA-m2m5-rw3w-cwmj ApostropheCMS: Client-side Prototype Pollution in apos.http.parseQuery leading to DOM-based XSS against authenticated users
MEDIUM
Official page ↗
- Aliases
- CVE-2026-113659
- Ecosystem
- npm
- Packages
- apostrophe
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
- Weaknesses
- CWE-1321, CWE-79
- Reviewed
- 2026-10-20
Affected packages
• npm apostrophe: < 4.33.0 → fixed in 4.33.0
Description
Summary
The client-side helper `apos.http.parseQuery()` in ApostropheCMS builds a nested object
from a URL query string without sanitizing the keys `__proto__`, `constructor`, or
`prototype`. Parsing an attacker-controlled query string therefore writes arbitrary
properties onto `Object.prototype` (client-side prototype pollution).
ApostropheCMS's admin UI calls this parser with `window.location.search` on every
authenticated page load (the admin context bar's `data()` initializer). Because the admin
UI is built on Vue, a polluted, enumerable property on `Object.prototype` is then picked up
by Vue's standard element-mount routine and written to the DOM as an element property,
turning the prototype pollution into DOM-based XSS in the victim's authenticated session.
The escalation gadget is a well-known, general Vue behavior; the novel,
reportable defect is the unsanitized `parseQuery` source together with the authenticated
trigger, both of which are ApostropheCMS code.
Any user who can get any logged-in user (from the lowest-privileged `guest` role up to the highest-privileged `admin` role) to
open a crafted URL can execute arbitrary JavaScript in that user's session.
This is distinct from CVE-2026-53609 (server-side prototype pollution via
`apos.util.set()` / `$pullAll`, fixed in 4.31.0): different side (client vs server),
different code path, and the vulnerable code here is present in the current release.
Package
• Ecosystem: npm
• Package: `apostrophe`
• Affected versions: all 4.x releases, 4.0.0 through 4.32.2 (current `latest`).
Verified by inspecting the published npm tarballs: `[email protected]` already contains the
vulnerable descent (`parentKey = 'root'`) with no `__proto__`/`constructor`/`prototype`
filtering, and `[email protected]` is unchanged in this respect. The vulnerable
`parseQuery()` logic was introduced in commit `0255086bf` (2020-11-12), well before the 4.0
version, so ApostropheCMS 3.x is also affected (the same code ships in 3.x at the older
build path `modules/@apostrophecms/util/ui/public/2-http.js`).
• Patched versions: none at time of writing.
Details
The issue has three parts. Parts 1 and 2 are ApostropheCMS code and constitute the
reportable vulnerability. Part 3 is the escalation gadget, which is standard Vue behavior
and is included for completeness.
1. The pollution primitive: source (ApostropheCMS)
`modules/@apostrophecms/util/ui/src/http.js`, function `apos.http.parseQuery()`
(line ~301) and its inner `patch()` (lines ~317–354):
```js
function patch(key, value) {
let match;
let parentKey = 'root';
let context = data; // data = {} (ordinary object)
key = decodeURIComponent(key);
const path = key.split('[');
path.forEach(function(subKey) {
if (subKey === ']') {
/* array handling */
} else if (subKey.match(/^\d+]/)) {
/* numeric index handling */
} else {
match = subKey.replace(']', '');
if (!context[parentKey]) { // (A) truthy check, not an own-property check
context[parentKey] = {};
}
context = context[parentKey]; // (B) descends into whatever this resolves to
parentKey = match;
}
});
value = (value === null) ? value : decodeURIComponent(value);
if (Array.isArray(context[parentKey])) {
context[parentKey].push(value);
} else if (context[parentKey] !== undefined) {
context[parentKey] = [ context[parentKey], value ];
} else {
context[parentKey] = value; // (C) assignment onto the resolved object
}
}
```
No `__proto__`, `constructor`, or `prototype` key is rejected. Walking the input
`__proto__[x]=v`:
1. Segment `__proto__`: at (A), `context['__proto__']` resolves to the object's
inherited prototype (truthy), so the `{}` initializer is skipped; at (B), `context`
becomes `Object.prototype`.
2. Segment `x`: at (C), `Object.prototype['x'] = v`.
The truthy guard at (A) (rather than an own-property check) is what allows the descent
into the inherited prototype. Because the assignment at (C) is an ordinary property
write, the resulting `Object.prototype` property is enumerable,which matters for the
gadget (3).
2. The trigger: reached on every authenticated page load (ApostropheCMS)
`modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue`. The component's
`data()` initializer (line ~64) runs when the admin context bar mounts (i.e. on every
authenticated page load), and passes the raw query string straight into the vulnerable
parser:
```js
data() {
const query = apos.http.parseQuery(location.search); // (1) pollution, on mount
const draftMode = query.aposMode || 'published';
// ...
}
```
The same component's `refresh()` method (line ~606) passes `window.location.search` into
`parseQuery()` again on admin-bar refresh events, and folds it into `doc._url`
(re-parsed inside `apos.http.remote()`), providing additional trigger points.
The admin context bar mounts only when a session is present and the admin bundle loads, so
none of this executes for anonymous visitors, which is why the pollution is observed only
when logged in. All of this is core ApostropheCMS code (`node_modules/apostrophe/...`);
it is not introduced by any project or starter kit content.
3. The escalation gadget: Vue element mount (framework behavior; known, not novel)
The demonstrated payload pollutes the property `innerHTML`
(`__proto__[innerHTML]=`). The sink that consumes it is Vue's
standard element-mount path, not ApostropheCMS code. Confirmed in the bundled framework
(`vue` / `@vue/runtime-core` / `@vue/runtime-dom` all 3.5.42):
`@vue/runtime-core` `mountElement`, prop enumeration (line ~5741):
```js
if (props) {
for (const key in props) { // for...in walks the prototype chain
if (key !== "value" && !shared.isReservedProp(key)) { // isReservedProp does NOT list innerHTML
hostPatchProp(el, key, null, props[key], ...); // props["innerHTML"] → inherited polluted value
}
}
```
`@vue/runtime-dom` `patchDOMProp` (line ~583), reached via `hostPatchProp`:
```js
function patchDOMProp(el, key, value, ...) {
if (key === "innerHTML" || key === "textContent") {
if (value != null) {
el[key] = key === "innerHTML" ? unsafeToTrustedHTML(value) : value; // el.innerHTML = payload
}
return;
}
```
Mechanism: whenever Vue mounts an element that has a non-null `props` object without its own
`innerHTML` (routine during the admin render), `for (const key in props)` enumerates the
inherited, enumerable `innerHTML` from `Object.prototype`; it passes the `"value"` and
`isReservedProp` filters; `props["innerHTML"]` resolves to the attacker's payload; and
`patchDOMProp` writes it to `el.innerHTML`. `unsafeToTrustedHTML` is Vue's Trusted-Types
wrapper, not a sanitizer, a passthrough unless Trusted Types is enforced (not the default).
This gadget is a well-documented, essentially universal client-side-PP > XSS escalation in Vue applications.
It is not claimed as a novel finding and is not ApostropheCMS's to fix, it cannot
be removed without changing Vue. Its relevance here is that it makes the (1) pollution
reliably exploitable in any ApostropheCMS admin session.
Proof of Concept
Prerequisite: a running ApostropheCMS instance and any authenticated session (a `guest`
account suffices). The tester/victim must be logged in.
A. Prototype pollution (console-verifiable):
1. Log in as any user.
2. Navigate to: `https://TARGET/?__proto__[prop]=polluted`
3. In the browser console: `Object.prototype.prop` > returns `"polluted"`.
B. DOM-based XSS:
1. Log in as any user.
2. Navigate to:
`https://TARGET/?__proto__[innerHTML]=%3Cimg/src/onerror%3dalert(1)%3E`
3. `alert(1)` executes in the victim's authenticated origin.
To capture the sink stack for evidence: DevTools …
Related
Alias · CVE (1)
- CVE-2026-113659 ApostropheCMS is an open-source Node.js content management system. vulnerability
References
- https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-m2m5-rw3w-cwmj web
- https://github.com/apostrophecms/apostrophe/commit/9500b8b0e1d630d5e07fbb5fd2c25ed0943845ce web
- https://github.com/apostrophecms/apostrophe/commit/f84fd41cbf6c22eb8d0008fc9c25df1f7130b350 web
- https://github.com/apostrophecms/apostrophe package
- https://github.com/apostrophecms/apostrophe/releases/tag/[email protected] web