---
id: CVE-2026-112954
aliases:
  - GHSA-rqgm-cfrp-4hx8
title: >-
  ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort
  query builders in the public REST API
summary: >-
  ApostropheCMS: Unauthenticated publicApiProjection bypass via search and sort
  query builders in the public REST API
severity: medium
cvss: 5.3
cwe:
  - CWE-200
vendor: apostrophe
product: apostrophe
ecosystem: npm
affected:
  - apostrophe < 4.34.0
patched:
  - apostrophe 4.34.0
published: '2026-10-21'
updated: '2026-10-21'
sourceUpdated: '2026-10-21T17:12:17Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-rqgm-cfrp-4hx8'
references:
  - url: >-
      https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rqgm-cfrp-4hx8
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-112954'
  - url: >-
      https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498
  - url: >-
      https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea
  - url: 'https://github.com/apostrophecms/apostrophe/releases/tag/apostrophe@4.34.0'
  - url: 'https://github.com/advisories/GHSA-rqgm-cfrp-4hx8'
tags:
  - ghsa
  - npm
  - cve.org
epss: 0.00307
epssPercentile: 0.22247
ingestedAt: '2026-10-20T16:10:53.293Z'
---

## Overview

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

## Affected packages

- `apostrophe < 4.34.0`

## Remediation

Upgrade to a patched release:

- `apostrophe 4.34.0`
