medium

CVE-2026-113659

npm · apostrophe

Summary

ApostropheCMS: Client-side Prototype Pollution in apos.http.parseQuery leading to DOM-based XSS against authenticated users

Severity
medium
CVSS
6.1
EPSS
0.3% (p28)
CWE
CWE-79, CWE-1321
Also known as
GHSA-m2m5-rw3w-cwmj
Published
2026-10-20
Updated
2026-10-20

Advisory details

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

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:

  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:

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 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]=<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 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

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.