---
id: "CVE-2026-113659"
kind: "vulnerability"
aliases: ["CVE-2026-113659", "GHSA-m2m5-rw3w-cwmj"]
title: "ApostropheCMS: Client-side Prototype Pollution in apos.http.parseQuery leading to DOM-based XSS against authenticated users"
severity: "medium"
cvss: 6.1
epss: 0.003449999960139394
exploited: false
status: "open"
category: "package"
products: [{"name": "apostrophe", "fixed": ["4.33.0"], "affected": "< 4.33.0", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["apostrophe", "cms", "cwe-1321", "cwe-79", "node", "package", "self-hosted"]
not_affected_if: ["no-logged-in-users", "not-on-affected-version"]
published_at: "2026-10-20T18:17:19+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:21+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-m2m5-rw3w-cwmj", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113659", "first_seen_at": "2026-10-23T21:57:21+00:00"}]
---

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

ApostropheCMS's client-side parseQuery helper does not block __proto__, constructor or prototype keys, so a crafted URL pollutes Object.prototype in the browser of any logged-in user. Using a Vue gadget described in the report, this escalates to DOM-based XSS in the victim's authenticated session, which can reach admin APIs when the victim is an editor or admin.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:apostrophe affected < 4.33.0 fixed 4.33.0

## How to check

Check the installed version: npm ls apostrophe (affected: 4.0.0 through 4.32.2, and the report says 3.x is also affected).
Search node_modules/apostrophe/modules/@apostrophecms/util/ui/src/http.js for parseQuery and see whether it rejects __proto__, constructor or prototype.

## Fix

Upgrade apostrophe to 4.33.0 or later (fixed in 4.33.0 per sources).
Interim: restrict who holds authenticated accounts and avoid opening untrusted links while logged in; the report says no server-side toggle exists.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-m2m5-rw3w-cwmj
- 2026-10-23T21:57:21+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-113659

## From the sources

## 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: `apostrophe@4.0.0` already contains the
  vulnerable descent (`parentKey = 'root'`) with no `__proto__`/`constructor`/`prototype`
  filtering, and `apostrophe@4.32.2` 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);   // <- 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):

```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

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
