CVE-2026-35569
Description
ApostropheCMS is an open-source Node.js content management system. Versions 4.28.0 and prior contain a stored cross-site scripting vulnerability in SEO-related fields (SEO Title and Meta Description), where user-controlled input is rendered without proper output encoding into HTML contexts including <title> tags, <meta> attributes, and JSON-LD structured data. An attacker can inject a payload such as "></title><script>alert(1)</script> to break out of the intended HTML context and execute arbitrary JavaScript in the browser of any authenticated user who views the affected page. This can be leveraged to perform authenticated API requests, access sensitive data such as usernames, email addresses, and roles via internal APIs, and exfiltrate it to an attacker-controlled server. This issue has been fixed in version 4.29.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
apostrophenpm | < 4.29.0 | 4.29.0 |
Affected products
1Patches
10e57dd07a56aMerge commit from fork
5 files changed · +118 −12
.changeset/thin-pears-search.md+6 −0 added@@ -0,0 +1,6 @@ +--- +"apostrophe": minor +"@apostrophecms/seo": patch +--- + +Fix an XSS vulnerability allowing arbitrary markup to be inserted via the "SEO Title" or "Meta Description" fields provided by the @apostrophecms/seo module. The fix requires upgrading BOTH apostrophe and @apostrophecms/seo. A new mechanism for safely emitting JSON nodes has been introduced to make this type of vulnerability unlikely in the future. Thanks to [K Shanmukha Srinivasulu Royal](https://github.com/Chittu13) for reporting the vulnerability.
packages/apostrophe/lib/safe-json-script.js+27 −0 added@@ -0,0 +1,27 @@ +// Serialize `data` to a JSON string that is safe to embed inside an HTML +// `<script>` element. `JSON.stringify` on its own does NOT escape the +// sequences `</script>`, `<!--` or `<![CDATA[`, so untrusted data (e.g. +// editor-provided SEO fields) in a JSON body could otherwise break out of +// the surrounding script tag and inject arbitrary HTML/JS (stored XSS). +// Escaping `<` as its `\u003c` form keeps the JSON valid while neutralizing +// all of those sequences. Line and paragraph separators are also escaped +// since they are valid in JSON but illegal in some JavaScript parsers. +// +// This is the single source of truth for that escaping. The template +// `renderNodes` helper uses it to render `{ json: ... }` node bodies, so in +// most cases you should just build a node like: +// +// { +// name: 'script', +// attrs: { type: 'application/ld+json' }, +// body: [ { json: data } ] +// } +// +// and let `renderNodes` do the right thing. + +module.exports = function safeJsonForScript(data) { + return JSON.stringify(data, null, 2) + .replace(/</g, '\\u003c') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +};
packages/apostrophe/modules/@apostrophecms/template/index.js+18 −3 modified@@ -35,6 +35,7 @@ const path = require('path'); const { stripIndent } = require('common-tags'); const { SemanticAttributes } = require('@opentelemetry/semantic-conventions'); const voidElements = require('void-elements'); +const safeJsonForScript = require('../../../lib/safe-json-script'); module.exports = { options: { alias: 'template' }, @@ -1140,12 +1141,23 @@ module.exports = { // attrs: { href: '/some/path', rel: 'stylesheet' } // } // ] - // Node object SHOULD have either `name`, `text`, `raw` or `comment` property. - // A node with `name` can have `attrs` (array of element attributes) - // and `body` (array of child nodes, recursion). + // Node object SHOULD have either `name`, `text`, `raw`, `json` or + // `comment` property. A node with `name` can have `attrs` (array of + // element attributes) and `body` (array of child nodes, recursion). // `text` nodes are rendered as text (no HTML tags), the value is always a string. // `comment` nodes are rendered as HTML comments, the value is always a string. // `raw` nodes are rendered as is, no escaping, the value is always a string. + // `json` nodes are rendered as a JSON serialization of the value, + // safely escaped for inclusion inside a `<script>` element so that + // untrusted content cannot break out of the surrounding script tag. + // Use this instead of building a `raw` body from `JSON.stringify()` + // yourself, e.g.: + // + // { + // name: 'script', + // attrs: { type: 'application/ld+json' }, + // body: [ { json: myData } ] + // } renderNodes(nodes) { if (!Array.isArray(nodes)) { self.logError( @@ -1164,6 +1176,9 @@ module.exports = { if (node.raw != null) { return node.raw; } + if (node.json != null) { + return safeJsonForScript(node.json); + } if (node.name != null) { const name = self.apos.util.escapeHtml(node.name); const attrs = Object.entries(node.attrs || {})
packages/seo/lib/nodes.js+6 −9 modified@@ -241,21 +241,18 @@ function getMetaHead(data, options) { '@graph': schemas }; - const jsonLdString = JSON.stringify(jsonLdData, null, 2); - nodes.push({ comment: ' JSON-LD Structured Data ' }); - const scriptNode = { + // A `json` body is escaped by renderNodes so that untrusted content + // in SEO fields cannot break out of the surrounding script element + // (stored XSS). + nodes.push({ name: 'script', attrs: { type: 'application/ld+json' }, - body: [ { - raw: jsonLdString - } ] - }; - - nodes.push(scriptNode); + body: [ { json: jsonLdData } ] + }); } } catch (err) { if (process.env.APOS_SEO_DEBUG) {
packages/seo/test/unit-tests.js+61 −0 modified@@ -1657,6 +1657,67 @@ describe('@apostrophecms/seo', function () { }); }); + describe('XSS prevention in JSON-LD', function () { + it('should emit JSON-LD as a json node so </script> cannot break out', function () { + const { getMetaHead } = require('../lib/nodes'); + const safeJsonForScript = require('apostrophe/lib/safe-json-script'); + + const payload = '"></title><script>alert(1)</script>'; + + const data = { + page: { + title: 'XSS Test', + seoTitle: payload, + seoDescription: payload, + seoJsonLdType: 'WebPage', + _url: 'https://example.com/xss-test' + }, + global: { + seoSiteName: 'Test Site', + seoSiteCanonicalUrl: 'https://example.com' + }, + req: {} + }; + + const nodes = getMetaHead(data, {}); + + const jsonLdNode = nodes.find(n => + n.name === 'script' && + n.attrs && + n.attrs.type === 'application/ld+json' + ); + + assert(jsonLdNode, 'JSON-LD script node should exist'); + assert( + Array.isArray(jsonLdNode.body) && jsonLdNode.body[0], + 'JSON-LD script should have a body' + ); + + // The body must be a `json` node, not a pre-serialized `raw` string, + // so that renderNodes performs the safe-for-script escaping on our + // behalf. Raw-serializing JSON inline is the exact footgun we are + // trying to remove. + assert( + jsonLdNode.body[0].json != null, + 'JSON-LD body should be a json node, not a raw string' + ); + + // Belt and suspenders: verify that when that json value is actually + // rendered through the safe encoder, the literal `</script` sequence + // (which would terminate the surrounding <script> element) is gone. + const rendered = safeJsonForScript(jsonLdNode.body[0].json); + assert( + !/<\/script/i.test(rendered), + 'rendered JSON-LD must not contain an unescaped </script> sequence' + ); + // Sanity check: the payload is still there, just escaped. + assert( + rendered.includes('\\u003c/script'), + 'payload should survive in escaped form' + ); + }); + }); + describe('Schema Validation', function () { it('should validate Article schema requirements', function () {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/apostrophecms/apostrophe/commit/0e57dd07a56ae1ba1e3af646ba026db4d0ab5bb3nvdPatchWEB
- github.com/apostrophecms/apostrophe/security/advisories/GHSA-855c-r2vq-c292nvdExploitPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-855c-r2vq-c292ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-35569ghsaADVISORY
- github.com/Chittu13/cve-research/tree/main/CVE-2026-35569nvdWEB
News mentions
0No linked articles in our index yet.