GitHub Security Advisory: GHSA-97v6-998m-fp4g
Package: apostrophe (NPM)
Severity: MODERATE (CVSS 5.4)
CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Vulnerable versions: < 4.29.0
Patched in: 4.29.0

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 `--`:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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.**

```bash
# 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 CSR