CVE-2026-33888
Description
ApostropheCMS is an open-source Node.js content management system. Versions 4.28.0 and prior contain an authorization bypass vulnerability in the getRestQuery method of the @apostrophecms/piece-type module, where the method checks whether a MongoDB projection has already been set before applying the admin-configured publicApiProjection. An unauthenticated attacker can supply a project query parameter in the REST API request, which is processed by applyBuildersSafely before the permission check, pre-populating the projection state and causing the publicApiProjection to be skipped entirely. This allows disclosure of any field on publicly queryable documents that the administrator explicitly restricted from the public API, such as internal notes, draft content, or metadata. Exploitation is trivial, requiring only appending query parameters to a public URL with no authentication. 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
200d472804bb6Merge commit from fork
3 files changed · +8 −1
.changeset/neat-tables-look.md+5 −0 added@@ -0,0 +1,5 @@ +--- +"apostrophe": patch +--- + +Security: resolve publicApiProjection bypass vulnerability for piece types. Thanks to [restriction](https://github.com/restriction) for reporting the issue and proposing the fix.
packages/apostrophe/modules/@apostrophecms/page/index.js+1 −0 modified@@ -2951,6 +2951,7 @@ database.`); _id: null }); } else { + // Note that we MUST NOT honor the "project" query builder here query.project({ ...self.options.publicApiProjection, cacheInvalidatedAt: 1
packages/apostrophe/modules/@apostrophecms/piece-type/index.js+2 −1 modified@@ -1126,7 +1126,8 @@ module.exports = { query.and({ _id: null }); - } else if (!query.state.project) { + } else { + // Note that we MUST NOT honor the "project" query builder here query.project({ ...self.options.publicApiProjection, cacheInvalidatedAt: 1
6c2b548dec2eMerge 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
5- github.com/apostrophecms/apostrophe/commit/00d472804bb622df36a761b6f2cf2b33b2d4ce80nvdPatchWEB
- github.com/apostrophecms/apostrophe/commit/6c2b548dec2e3f7a82e8e16736603f4cd17525aanvdPatchWEB
- github.com/apostrophecms/apostrophe/security/advisories/GHSA-xhq9-58fw-859pnvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-xhq9-58fw-859pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33888ghsaADVISORY
News mentions
0No linked articles in our index yet.