GHSA-RQGM-CFRP-4HX8
Vulnerability from github – Published: 2026-10-21 17:12 – Updated: 2026-10-21 17:12Summary
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]=1as 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:
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):
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):
# [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/_idof 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
searchbuilder when the projection excludes any searchable field.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "apostrophe"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.34.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-112954"
],
"database_specific": {
"cwe_ids": [
"CWE-200"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-21T17:12:15Z",
"nvd_published_at": "2026-10-20T16:17:06Z",
"severity": "MODERATE"
},
"details": "### Summary\nApostropheCMS 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:\n\n- use `?search=\u003cword\u003e` as a **value oracle** to confirm and locate documents by the content of a field the projection was meant to hide, and\n- use `?sort[hiddenField]=1` as an **ordering oracle** to rank documents by a hidden field\u0027s value.\n\nThis 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`.\n\n### Details\nFor an anonymous caller, `getRestQuery` (piece types: `modules/@apostrophecms/piece-type/index.js:1142`; pages: `modules/@apostrophecms/page/index.js`) does:\n\n```js\nconst query = self.find(req).attachments(true);\nquery.applyBuildersSafely(req.query); // (1) apply ALL launderable builders from the query string\n...\nquery.project({ ...self.options.publicApiProjection, cacheInvalidatedAt: 1 }); // (2) force read projection\nquery.set(\u0027publicApiProjection\u0027, self.options.publicApiProjection); // (3) used only by the choices/counts gate\n```\n\n`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.\n\n**Search vector.** The `search` builder adds `{ $text: { $search: \u003cuser input\u003e } }` (`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` \u2192 `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.\n\n**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.\n\nNeither builder consults `publicApiProjection`, whereas `choices`/`counts` are gated at `doc-type/index.js:2807` via `choicesFieldAllowedByProjection`.\n\n### PoC\n**Preconditions:** the piece/page type sets `publicApiProjection`, and its schema has at least one searchable (default) or sortable field not included in the projection.\n\nTest type: `article` exposes only `title`, `slug`, `_id`, but has hidden `secret` (integer) and `secretNote` (searchable string):\n\n```js\nexport default {\n extend: \u0027@apostrophecms/piece-type\u0027,\n options: { publicApiProjection: { title: 1, slug: 1, _id: 1 } },\n fields: { add: {\n secret: { type: \u0027integer\u0027 },\n secretNote: { type: \u0027string\u0027 } // searchable by default; NOT in the projection\n } }\n};\n```\n\nFive articles were inserted and published via the authenticated REST API:\n`A.secret=50, secretNote=\"zzsecretalpha\"`; `B=10/zzsecretbravo`; `C=90/zzsecretcharlie`; `D=30/zzsecretdelta`; `E=70/zzsecretecho`.\nThe insert responses confirm the hidden `secretNote` value is written into the search index, e.g. for A: `\"highSearchText\": \"... zzsecretalpha\"`, `\"lowSearchText\": \"... zzsecretalpha\"`.\n\nAnonymous requests (no credentials, no session):\n\n```bash\n# [1] Baseline: projection hides secret/secretNote on direct read\ncurl -s \u0027http://localhost:3000/api/v1/article?perPage=1\u0027\n\n# [2] Search oracle: value exists ONLY in hidden secretNote\ncurl -s \u0027http://localhost:3000/api/v1/article?search=zzsecretalpha\u0027\ncurl -s \u0027http://localhost:3000/api/v1/article?search=zznotpresentxyz\u0027\n\n# [3] Sort oracle: order by hidden integer field\ncurl -s \u0027http://localhost:3000/api/v1/article?sort%5Bsecret%5D=1\u0027\ncurl -s \u0027http://localhost:3000/api/v1/article?sort%5Bsecret%5D=-1\u0027\n\n# Negative control: projection override is blocked\ncurl -s \u0027http://localhost:3000/api/v1/article?project%5BsecretNote%5D=1\u0026perPage=1\u0027\n```\n\nResults (verified on ApostropheCMS 4.32.2, MongoDB 7.0):\n\n| Request (anonymous) | Observed | Verdict |\n| ------------------------- | ------------------------------------------------- | ------------------------------------- |\n| `GET /api/v1/article` | `_id,title,slug,type,metaType,cacheInvalidatedAt` | projection works on direct read |\n| `?search=zzsecretalpha` | only Article A (with textScore) | **leak**: locates doc by hidden field |\n| `?search=zznotpresentxyz` | 0 results | oracle confirmed |\n| `?sort[secret]=1` | B, D, A, E, C (= 10,30,50,70,90) | **leak**: full hidden ordering |\n| `?sort[secret]=-1` | C, E, A, D, B | reverse confirmed |\n| `?project[secretNote]=1` | secretNote absent | gated (projection override blocked) |\n\n### Impact\nUnauthenticated, network-reachable partial disclosure of fields the developer explicitly restricted with `publicApiProjection`:\n\n- **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.\n- **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.\n\nNo 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`).\n\n### Remediation\nGate `search`, `sort` (and `autocomplete`, which feeds `search`) on the public API path the same way `choices`/`counts` are gated. When `publicApiProjection` is in force:\n\n- **sort:** reject or drop any sort key not permitted by the projection, reusing `choicesFieldAllowedByProjection`.\n- **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.",
"id": "GHSA-rqgm-cfrp-4hx8",
"modified": "2026-10-21T17:12:15Z",
"published": "2026-10-21T17:12:15Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rqgm-cfrp-4hx8"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112954"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea"
},
{
"type": "PACKAGE",
"url": "https://github.com/apostrophecms/apostrophe"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/releases/tag/apostrophe@4.34.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort query builders in the public REST API"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
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.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.