CVE-2026-35569: Stored XSS in ApostropheCMS SEO Fields Enables RCE
ApostropheCMS ≤4.28.0 fails to encode SEO field output in title tags, meta attributes, and JSON-LD contexts, allowing stored XSS leading to authenticated API exfiltration.
A popular website-building tool used by thousands of organizations has a security hole that lets attackers inject malicious code into websites. Think of it like someone being able to slip a hidden trap door into your house blueprints — once it's built, visitors get caught in the trap without knowing it.
Here's how it works: ApostropheCMS is software that helps people build and manage websites. It lets them fill in fields like "SEO Title" and "Meta Description" — basically invisible text that search engines read and that affects how your site appears online. The problem is the software doesn't properly check what people type into these fields before displaying it on the website.
A hacker can sneak malicious code into these fields using special characters. When someone visits the website, the code activates in their browser. The attacker can then steal login information, hijack accounts, or make the site do things it shouldn't — like collecting passwords or spreading more malware.
This is particularly risky for businesses and organizations that use this software to run their public websites. If you manage a website using ApostropheCMS version 4.28 or earlier, you're potentially vulnerable.
Here's what you can actually do about it: First, if you run a website on this platform, update immediately to version 4.29 or later when it's available — don't wait. Second, if you can't update right away, talk to your hosting company about temporarily disabling public access to SEO editing until you can patch. Third, check your website's visitor logs for suspicious activity that might indicate someone has already exploited this. These steps take minutes but could prevent serious damage to your organization.
Want the full technical analysis? Click "Technical" above.
CVE-2026-35569 is a stored cross-site scripting vulnerability in ApostropheCMS versions 4.28.0 and prior. The bug lives in how the CMS renders SEO metadata fields — specifically SEO Title and Meta Description — into three distinct HTML output contexts: <title> tags, <meta> attribute values, and JSON-LD <script type="application/ld+json"> blocks. User-controlled content flows from the database into these contexts with zero output encoding, meaning a single field injection can break out of all three simultaneously depending on template render order.
CVSS 8.7 (HIGH). No authentication is required to store the payload if the attacker has content editor access (a common role in CMS deployments). Any authenticated user who subsequently views the affected page executes the injected script under their session context, enabling credential theft, privilege escalation, and internal API enumeration.
Affected Component
The vulnerability originates in ApostropheCMS's Nunjucks template layer, specifically in the SEO module's page-level metadata rendering. The relevant template partial responsible for injecting data.piece.seoTitle and data.piece.seoDescription directly into the HTML <head> block. The JSON-LD path is a secondary injection surface rendered by the structured data helper.
Affected versions: 4.28.0 and all prior 4.x releases. The 3.x line uses a different template architecture and is not affected by this specific code path.
Root Cause Analysis
Nunjucks, the templating engine used by ApostropheCMS, auto-escapes variables by default — but only when the template explicitly opts into escaping or when the variable is not passed through a | safe filter. The SEO module's metadata template uses direct interpolation into raw HTML attribute and tag contexts, bypassing the engine's auto-escape entirely for these specific fields.
The vulnerable rendering path in the Nunjucks template:
// modules/@apostrophecms/seo/views/seoHead.html (pseudocode representation)
// BUG: seoTitle and seoDescription are interpolated without | e filter
// Nunjucks {{ var }} in attribute contexts does NOT escape quotes by default
// when the surrounding context is already a raw HTML block
// Equivalent server-side logic (Node.js pseudocode):
function renderSeoHead(pageData) {
const seoTitle = pageData.seoTitle; // attacker-controlled
const seoDesc = pageData.seoDescription; // attacker-controlled
// BUG: no htmlspecialchars / escapeHtml applied before interpolation
return `
${seoTitle}
`;
// Payload: ">
// Breaks out of and simultaneously
}
The JSON-LD block provides a third injection path. Because seoTitle is also embedded inside a <script type="application/ld+json"> block as a raw string, an attacker can also inject </script> to break out of that context, even if the <title> injection is somehow patched in isolation.
This is a web application vulnerability — no heap corruption is involved. The relevant "memory" is the DOM as constructed by the browser parser when it processes the unencoded server response. The injection works by manipulating the HTML tokenizer state machine:
HTML PARSER STATE — BEFORE INJECTION (expected):
BYTE STREAM:
3c 74 69 74 6c 65 3e 4d 79 20 53 69 74 65 3c 2f | My Site
74 69 74 6c 65 3e 0a ... | title>...
TOKENIZER STATE: [TAG_OPEN] -> [TAG_NAME:"title"] -> [DATA:"My Site"] -> [END_TAG]
DOM: "My Site"
HTML PARSER STATE — AFTER INJECTION:
BYTE STREAM (seoTitle = "> with \u003c/script\u003e:
"name": {{ data.piece.seoTitle | jsonAttribute }}
// jsonAttribute implementation:
function jsonAttribute(value) {
return JSON.stringify(String(value))
.replace(/ breakout
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
// BEFORE (server-side field storage — no write-time sanitization):
async function savePageSeoFields(req, pageData) {
const update = {
seoTitle: req.body.seoTitle, // BUG: raw user input stored
seoDescription: req.body.seoDescription, // BUG: raw user input stored
};
await self.db.collection('aposDocs').updateOne(
{ _id: pageData._id },
{ $set: update }
);
}
// AFTER (defense-in-depth: strip HTML tags on write, encode on read):
async function savePageSeoFields(req, pageData) {
const update = {
seoTitle: stripTags(req.body.seoTitle), // remove tags at write
seoDescription: stripTags(req.body.seoDescription),
};
// Primary fix remains output encoding at template layer.
// stripTags is secondary hardening only.
await self.db.collection('aposDocs').updateOne(
{ _id: pageData._id },
{ $set: update }
);
}
Detection and Indicators
Indicators of compromise for this vulnerability pattern:
MONGODB — query for poisoned SEO fields:
db.aposDocs.find({
$or: [
{ seoTitle: { $regex: /<[^>]+>/i } },
{ seoDescription: { $regex: /<[^>]+>/i } },
]
}, { seoTitle:1, seoDescription:1, slug:1, _id:1 })
HTTP ACCESS LOG PATTERN — exfiltration beacon:
POST /api/v1/@apostrophecms/user (unexpected from browser sessions)
GET /api/v1/@apostrophecms/user?aposMode=draft
Followed by outbound request to non-CMS domain within same session
CSP VIOLATION REPORTS:
Look for script-src violations referencing external domains in
page contexts that should have no external scripts (content pages).
PAYLOAD SIGNATURES:
">
">