CVE-2026-39857
Description
ApostropheCMS is an open-source Node.js content management system. Versions 4.28.0 and prior contain an authorization bypass vulnerability in the choices and counts query parameters of the REST API, where these query builders execute MongoDB distinct() operations that bypass the publicApiProjection restrictions intended to limit which fields are exposed publicly. The choices and counts parameters are processed via applyBuildersSafely before the projection is applied, and MongoDB's distinct operation does not respect projections, returning all distinct values directly. The results are returned in the API response without any filtering against publicApiProjection or removeForbiddenFields. An unauthenticated attacker can extract all distinct field values for any schema field type that has a registered query builder, including string, integer, float, select, boolean, date, slug, and relationship fields. Fields protected with viewPermission are similarly exposed, and the counts variant additionally reveals how many documents have each distinct value. Both the piece-type and page REST APIs are affected. This issue has been fixed in version 4.29.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
apostrophenpm | < 4.29.0 | 4.29.0 |
Affected products
1Patches
16c2b548dec2eMerge commit from fork
6 files changed · +182 −0
.changeset/thin-rings-vanish.md+8 −0 added@@ -0,0 +1,8 @@ +--- +"apostrophe": patch +--- + +Fixed a security hole in the `.choices()` and `.counts()` query builders: formerly, these query builders could be used +by the public to exfiltrate schema fields not included in the `publicApiProjection`, or fields locked down with +a `viewPermission` property. Thanks to [offset](https://github.com/offset) for reporting this issue, which was not +made public prior to the release of the fix.
packages/apostrophe/modules/@apostrophecms/doc-type/index.js+67 −0 modified@@ -1621,6 +1621,59 @@ module.exports = { return doc; }, + // Returns true if the named filter is permitted to expose distinct + // values through the `choices` / `counts` query builders given the + // publicApiProjection. When no publicApiProjection is in effect + // (authenticated API callers), all fields are permitted. + // + // This guards against leaking distinct values of fields excluded by + // `publicApiProjection`, since MongoDB's `distinct` operator ignores + // projections. Projections set explicitly by authenticated users are + // not restricted — they are a voluntary narrowing of query results, + // not a security boundary. + choicesFieldAllowedByProjection(filter, projection) { + if (!projection || !Object.keys(projection).length) { + return true; + } + // Builders that aren't named after a top-level schema field are + // not gated by the projection. + const field = self.schema.find(f => f.name === filter); + if (!field) { + return true; + } + const topLevel = filter.split('.')[0]; + const values = Object.values(projection); + const hasInclusion = values.some(v => v && v !== 0); + const hasExclusion = values.some(v => v === 0 || v === false); + if (hasInclusion) { + // Inclusion projection: field must be explicitly included. + return Boolean(projection[topLevel]); + } + if (hasExclusion) { + // Exclusion projection: field must not be explicitly excluded. + return projection[topLevel] !== 0 && projection[topLevel] !== false; + } + return true; + }, + + // Returns true if the current user is permitted to view the named + // schema field according to any schema-level `viewPermission`. + // Non-schema filters are permitted. A dot-notated filter is matched + // against the top-level schema field, since `viewPermission` is + // declared on top-level fields (see `removeForbiddenFields`). + choicesFieldAllowedByViewPermission(req, filter) { + const topLevel = filter.split('.')[0]; + const field = self.schema.find(f => f.name === topLevel); + if (!field || !field.viewPermission) { + return true; + } + return self.apos.permission.can( + req, + field.viewPermission.action, + field.viewPermission.type + ); + }, + composeFilters() { // TODO: keep in sync with page/index.js composeFilters self.filters = Object.entries(self.filters) @@ -2707,6 +2760,7 @@ module.exports = { const choices = {}; const baseQuery = query.get('choices-query-prefinalize'); baseQuery.set('choices-query-prefinalize', null); + const publicApiProjection = query.get('publicApiProjection'); for (const filter of filters) { // The choices for each filter should reflect the effect of all // filters except this one (filtering by topic pairs down the list @@ -2722,6 +2776,19 @@ module.exports = { if (!query.builders[filter].launder) { continue; } + // Do not leak distinct values of fields excluded by the + // publicApiProjection. MongoDB's `distinct` ignores projections, + // so we must enforce this ourselves here. Only applies to the + // public API; authenticated users who set their own projections + // are not restricted. + if (!self.choicesFieldAllowedByProjection(filter, publicApiProjection)) { + continue; + } + // Do not leak distinct values of fields the current user does + // not have permission to view via a schema-level viewPermission. + if (!self.choicesFieldAllowedByViewPermission(query.req, filter)) { + continue; + } // Now shut it off _query[filter](null); choices[filter] = await _query.toChoices(filter, { counts: query.get('counts') });
packages/apostrophe/modules/@apostrophecms/page/index.js+1 −0 modified@@ -2955,6 +2955,7 @@ database.`); ...self.options.publicApiProjection, cacheInvalidatedAt: 1 }); + query.set('publicApiProjection', self.options.publicApiProjection); } } return query;
packages/apostrophe/modules/@apostrophecms/piece-type/index.js+1 −0 modified@@ -1131,6 +1131,7 @@ module.exports = { ...self.options.publicApiProjection, cacheInvalidatedAt: 1 }); + query.set('publicApiProjection', self.options.publicApiProjection); } } return query;
packages/apostrophe/test/pieces.js+25 −0 modified@@ -2364,6 +2364,31 @@ describe('Pieces', function() { assert.deepEqual(actual, expected); }); + it('should not leak viewPermission-protected fields via ?choices=', async function() { + const jar = await t.loginAs(apos, 'editor'); + + const req = apos.task.getReq(); + const candidate = { + ...apos.modules.board.newInstance(), + title: 'Icarus', + slug: 'icarus', + discontinued: 'April 2077' + }; + await apos.modules.board.insert(req, candidate); + + // Editor does not have 'publish' permission on 'board', so + // 'discontinued' (viewPermission: { action: 'publish', type: 'board' }) + // must not appear in choices + const response = await apos.http.get('/api/v1/board?choices=title,discontinued', { jar }); + assert(response); + assert(response.choices); + assert(response.choices.title); + assert( + !response.choices.discontinued || response.choices.discontinued.length === 0, + 'choices for viewPermission-protected field "discontinued" must not be exposed to editors' + ); + }); + it('should be able to edit fields with editPermission when having appropriate credentials on rest API', async function() { const jar = await t.loginAs(apos, 'editor');
packages/apostrophe/test/pieces-public-api.js+80 −0 modified@@ -118,6 +118,86 @@ describe('Pieces Public API', function() { assert(!response.results[0].foo); }); + it('should not leak distinct values of non-projected fields via ?choices=', async function() { + apos.thing.options.publicApiProjection = { + title: 1, + _url: 1 + }; + const response = await apos.http.get('/api/v1/thing?choices=title,foo'); + assert(response); + assert(response.choices); + // title IS in the public API projection, so its choices are allowed + assert(response.choices.title); + assert(response.choices.title.some(c => c.label === 'hello')); + // foo is NOT in the public API projection: its distinct values + // must not be exposed to anonymous callers + assert( + !response.choices.foo || response.choices.foo.length === 0, + 'choices for non-projected field "foo" must not be exposed publicly' + ); + }); + + it('should not leak distinct values of non-projected fields via ?counts=', async function() { + apos.thing.options.publicApiProjection = { + title: 1, + _url: 1 + }; + const response = await apos.http.get('/api/v1/thing?counts=title,foo'); + assert(response); + assert(response.counts); + assert(response.counts.title); + assert( + !response.counts.foo || response.counts.foo.length === 0, + 'counts for non-projected field "foo" must not be exposed publicly' + ); + }); + + it('should not leak non-projected fields via dot-notated ?choices= filter names', async function() { + apos.thing.options.publicApiProjection = { + title: 1, + _url: 1 + }; + // A dot-notated filter whose top-level segment is not projected + // must be blocked the same way the plain field name is. + const response = await apos.http.get('/api/v1/thing?choices=foo.bar'); + assert(response); + assert( + !response.choices || !response.choices['foo.bar'] || response.choices['foo.bar'].length === 0, + 'choices for dot-notated non-projected field "foo.bar" must not be exposed publicly' + ); + }); + + it('should still expose choices for non-projected fields to authenticated API users', async function() { + apos.thing.options.publicApiProjection = { + title: 1, + _url: 1 + }; + const req = apos.task.getReq(); + // Simulate a full-API caller: canAccessApi is true, no projection applied + const query = apos.thing.find(req).choices([ 'foo' ]); + await query.toArray(); + const choices = query.get('choicesResults'); + assert(choices); + assert(choices.foo); + assert(choices.foo.some(c => c.value === 'bar')); + }); + + it('should not restrict choices when an authenticated user voluntarily sets a projection', async function() { + apos.thing.options.publicApiProjection = { + title: 1, + _url: 1 + }; + const req = apos.task.getReq(); + // Authenticated user narrows their own query via project() — this is + // voluntary and must not block choices for fields outside that projection + const query = apos.thing.find(req).project({ title: 1 }).choices([ 'foo' ]); + await query.toArray(); + const choices = query.get('choicesResults'); + assert(choices); + assert(choices.foo); + assert(choices.foo.some(c => c.value === 'bar')); + }); + it('should not set a "max-age" cache-control value when retrieving pieces, when cache option is not set, with a public API projection', async function() { apos.thing.options.publicApiProjection = { title: 1,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/apostrophecms/apostrophe/commit/6c2b548dec2e3f7a82e8e16736603f4cd17525aanvdPatchWEB
- github.com/apostrophecms/apostrophe/security/advisories/GHSA-c276-fj82-f2pqnvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-c276-fj82-f2pqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-39857ghsaADVISORY
News mentions
0No linked articles in our index yet.