Summary
ApostropheCMS: Client-side Prototype Pollution in apos.http.parseQuery leading to DOM-based XSS against authenticated users
The client-side helper apos.http.parseQuery() in ApostropheCMS builds a nested object
from a URL query string without sanitizing the keys __proto__, constructor, orprototype. 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 viaapos.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 vulnerableparseQuery()logic was introduced in commit0255086bf(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 pathmodules/@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):
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:
- Segment
__proto__: at (A),context['__proto__']resolves to the object's
inherited prototype (truthy), so the{}initializer is skipped; at (B),context
becomesObject.prototype. - 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'sdata() 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:
data() {
const query = apos.http.parseQuery(location.search); // <- attacker-controlled -> (1) pollution, on mount
const draftMode = query.aposMode || 'published';
// ...
}
The same component's refresh() method (line ~606) passes window.location.search intoparseQuery() 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]=<img/src/onerror=alert(1)>). 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):
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:
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 owninnerHTML (routine during the admin render), for (const key in props) enumerates the
inherited, enumerable innerHTML from Object.prototype; it passes the "value" andisReservedProp filters; props["innerHTML"] resolves to the attacker's payload; andpatchDOMProp 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):
- Log in as any user.
- Navigate to:
https://TARGET/?__proto__[prop]=polluted - In the browser console:
Object.prototype.prop> returns"polluted".
B. DOM-based XSS:
- Log in as any user.
- Navigate to:
https://TARGET/?__proto__[innerHTML]=%3Cimg/src/onerror%3dalert(1)%3E alert(1)executes in the victim's authenticated origin.
To capture the sink stack for evidence: DevTools > Sources > open the admin bundle >
pretty-print > search unsafeToTrustedHTML (or === "innerHTML") > line breakpoint in thepatchDOMProp innerHTML branch > reload the logged-in payload URL > inspect value and
walk the call stack up through hostPatchProp to mountElement's for…in.
Both A and B were reproduced against ApostropheCMS 4.32.2 (Vue 3.5.42) in Chrome, Firefox, and Edge browsers.
References
- Source (ApostropheCMS):
apostrophe/modules/@apostrophecms/util/ui/src/http.js,parseQuery()/patch() - Trigger (ApostropheCMS):
apostrophe/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue,data()(~line 64),refresh()(~line 606) - Escalation gadget (Vue 3.5.42, framework behavior):
@vue/runtime-coremountElement
prop enumeration (~line 5741);@vue/runtime-dompatchDOMPropinnerHTML branch
(~line 583) - Related but distinct: CVE-2026-53609 / GHSA-6h5j-32cf-4253 (server-side prototype
pollution viaapos.util.set()/$pullAll, fixed in 4.31.0) - CWE-1321; CWE-79
Credit
Github Account Username: Xx-LoGiCaL-xX
Impact
Any party who can coerce an authenticated ApostropheCMS user to open a crafted URL can
execute arbitrary JavaScript in that user's session. Because the escalation gadget is a
guaranteed-present Vue behavior, this is a reliable exploit, not a theoretical primitive.
Consequences scale with the victim's role:
- Any authenticated victim: actions and data reads available to that account; theft of
the session cookie / CSRF token from the authenticated context. - Editor/admin victim: the injected script can invoke the authenticated management REST
APIs as the victim, creating or modifying documents and user accounts, changing
configuration and possibly leading to account takeover and potential full compromise of the CMS
instance.
Prototype pollution can additionally affect unrelated code paths that read inherited
properties, so other gadgets beyond the demonstrated Vue innerHTML sink may exist.
Untrusted input is rendered as active markup in a victim's browser, which can run script in their session. Typical impact: session or credential theft, and actions taken as the user.
CVE-2026-113659 has a CVSS score of 6.1 (Medium). The vector is network-reachable, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (4.33.0); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
The fix belongs in ApostropheCMS, at the source (1). The gadget (3) is framework behavior
and cannot be relied upon to change, so sanitizing parseQuery is the correct fix.
Primary fix: reject prototype-affecting keys in parseQuery/patch():
apos.http.parseQuery = function(query) {
query = query.replace(/^\?/, '');
const data = {};
const DANGEROUS = new Set([ '__proto__', 'constructor', 'prototype' ]);
query.split('&').forEach(function(pair) {
// ... unchanged ...
});
return data.root || {};
function patch(key, value) {
let match;
let parentKey = 'root';
let context = data;
key = decodeURIComponent(key);
const path = key.split('[');
let dangerous = false;
path.forEach(function(subKey) {
const cleanKey = subKey.replace(']', '');
if (DANGEROUS.has(cleanKey)) { // refuse to traverse into prototype-affecting keys
dangerous = true;
return;
}
// ... existing subKey handling ...
});
if (dangerous) {
return; // discard this parameter entirely
}
// ... existing assignment ...
}
};
Defense in depth:
- Replace the truthy guard at (A) with an own-property check:
if (!Object.prototype.hasOwnProperty.call(context, parentKey)), so a key can never
descend into an inherited prototype. - Build
dataand any created containers withObject.create(null)so intermediate objects
have no prototype chain to reach.
Interim mitigation for operators: restrict who holds authenticated accounts, and be
aware that opening untrusted links while logged in can trigger this. There is no server-side
configuration toggle that disables the vulnerable client behavior.
Frequently Asked Questions
- What is CVE-2026-113659? CVE-2026-113659 is a medium-severity cross-site scripting (XSS) vulnerability in apostrophe (npm), affecting versions < 4.33.0. It is fixed in 4.33.0. Untrusted input is rendered as active markup in a victim's browser, which can run script in their session.
- How severe is CVE-2026-113659? CVE-2026-113659 has a CVSS score of 6.1 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of apostrophe are affected by CVE-2026-113659? apostrophe (npm) versions < 4.33.0 is affected.
- Is there a fix for CVE-2026-113659? Yes. CVE-2026-113659 is fixed in 4.33.0. Upgrade to this version or later.
- Is CVE-2026-113659 exploitable, and should I be worried? Whether CVE-2026-113659 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-113659 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-113659? Upgrade
apostropheto 4.33.0 or later.