{"schema":"threatline.record/2","record":{"id":"CVE-2026-112954","aliases":["GHSA-rqgm-cfrp-4hx8"],"summary":"ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort query builders in the public REST API","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=<word>` 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's 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('publicApiProjection', 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: <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.\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: '@apostrophecms/piece-type',\n  options: { publicApiProjection: { title: 1, slug: 1, _id: 1 } },\n  fields: { add: {\n    secret:     { type: 'integer' },\n    secretNote: { type: 'string' }   // 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 'http://localhost:3000/api/v1/article?perPage=1'\n\n# [2] Search oracle: value exists ONLY in hidden secretNote\ncurl -s 'http://localhost:3000/api/v1/article?search=zzsecretalpha'\ncurl -s 'http://localhost:3000/api/v1/article?search=zznotpresentxyz'\n\n# [3] Sort oracle: order by hidden integer field\ncurl -s 'http://localhost:3000/api/v1/article?sort%5Bsecret%5D=1'\ncurl -s 'http://localhost:3000/api/v1/article?sort%5Bsecret%5D=-1'\n\n# Negative control: projection override is blocked\ncurl -s 'http://localhost:3000/api/v1/article?project%5BsecretNote%5D=1&perPage=1'\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.","categories":["Libraries & packages"],"product":"apostrophe","vendor":"apostrophecms","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-rqgm-cfrp-4hx8","source_modified_at":"2026-10-21T17:12:17Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"apostrophe"},"range":"< 4.34.0"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-112954","source_modified_at":"2026-10-23T01:50:55.523Z","fetched_at":"2026-10-23T18:42:43.628194Z","product":"apostrophe","vendor":"apostrophecms","versions":[{"version":"< 4.34.0","status":"affected"}],"defaultStatus":null},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-rqgm-cfrp-4hx8","source_modified_at":"2026-10-21T17:15:08.640805945Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"name":"apostrophe","ecosystem":"npm","purl":"pkg:npm/apostrophe"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"4.34.0"}]}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-rqgm-cfrp-4hx8","source_modified_at":"2026-10-21T17:12:17Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"apostrophe"},"version":"4.34.0"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-rqgm-cfrp-4hx8","source_modified_at":"2026-10-21T17:15:08.640805945Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"name":"apostrophe","ecosystem":"npm","purl":"pkg:npm/apostrophe"},"version":"4.34.0"}],"cvss":5.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":0.00307,"percentile":0.22333,"score_date":"2026-10-23"},"published_at":"2026-10-20T15:51:01.621Z","modified_at":"2026-10-23T01:50:55.523Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-rqgm-cfrp-4hx8","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-21T17:12:17Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-112954","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23T01:50:55.523Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-rqgm-cfrp-4hx8","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-21T17:15:08.640805945Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-112954","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rqgm-cfrp-4hx8","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112954","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/apostrophecms/apostrophe/releases/tag/apostrophe@4.34.0","tags":["x_refsource_MISC"]},{"url":"https://github.com/advisories/GHSA-rqgm-cfrp-4hx8","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"yes","technical_impact":"partial","assessed_at":"2026-10-23T01:50:29.890744Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-23T01:50:55.523Z","content_hash":"1e9effa8bfa15f53c786f5bdccfbb6f8e1215729e6a208aa738ac97c3b1624c0"}}
