GitHub Advisoryadvisory · npm
GHSA-rqgm-cfrp-4hx8 ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort query builders in the public REST API
MEDIUM
Official page ↗
- Aliases
- CVE-2026-112954
- Ecosystem
- npm
- Packages
- apostrophe
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
- Weaknesses
- CWE-200
- Reviewed
- 2026-10-21
Affected packages
• npm apostrophe: < 4.34.0 → fixed in 4.34.0
Description
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=` 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: } }` (`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.
Related
Alias · CVE (1)
- CVE-2026-112954 ApostropheCMS is an open-source Node.js content management system. vulnerability
Weakness · CWE (1)
- CWE-200 Exposure of Sensitive Information to an Unauthorized Actor weakness
References
- https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rqgm-cfrp-4hx8 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-112954 advisory
- https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498 web
- https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea web
- https://github.com/apostrophecms/apostrophe package
- https://github.com/apostrophecms/apostrophe/releases/tag/[email protected] web