VYPR
High severity8.7NVD Advisory· Published Apr 15, 2026· Updated Apr 30, 2026

CVE-2026-35569

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.

PackageAffected versionsPatched versions
apostrophenpm
< 4.29.04.29.0

Affected products

1

Patches

1
0e57dd07a56a

Merge commit from fork

https://github.com/apostrophecms/apostropheTom BoutellApr 15, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.