CVE-2026-33889
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 the @apostrophecms/color-field module, where color values prefixed with -- bypass TinyColor validation intended for CSS custom properties, and the launder.string() call performs only type coercion without stripping HTML metacharacters. These unsanitized values are then concatenated directly into <style> tags both in per-widget style elements rendered for all visitors and in the global stylesheet rendered for editors, with the output marked as safe HTML. An editor can inject a value which closes the style tag and executes arbitrary JavaScript in the browser of every visitor to any page containing the affected widget. This enables mass session hijacking, cookie theft, and privilege escalation to administrative control if an admin views draft content. This issue has been fixed in version 4.29.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
apostrophenpm | < 4.29.0 | 4.29.0 |
Affected products
1Patches
16a89bdb7acdbMerge commit from fork
4 files changed · +57 −5
.changeset/orange-toes-battle.md+6 −0 added@@ -0,0 +1,6 @@ +--- +"apostrophe": patch +--- + +Security: fixed an XSS vulnerability. Color fields formerly accepted -- followed by anything, including </style>, which could be used to inject other markup. Thanks to [restriction](https://github.com/restriction) for reporting the issue and proposing the fix. +
packages/apostrophe/modules/@apostrophecms/color-field/index.js+7 −1 modified@@ -32,9 +32,15 @@ module.exports = { throw self.apos.error('required'); } + const isVariable = destination[field.name].startsWith('--'); const test = new TinyColor(destination[field.name]); - if (!test.isValid && !destination[field.name].startsWith('--')) { + if (!test.isValid && !isVariable) { destination[field.name] = null; + } else if (isVariable) { + // CSS custom property names: only allow alphanumeric, hyphens, underscores + if (!/^--[a-zA-Z0-9_-]+$/.test(destination[field.name])) { + destination[field.name] = null; + } } }, isEmpty: function (field, value) {
packages/apostrophe/modules/@apostrophecms/styles/lib/methods.js+5 −1 modified@@ -125,14 +125,16 @@ module.exports = (self, options) => { }); } if (!hasLink) { + // Prevent an XSS attack even if a styles exploit is found + const css = (req.data.global.stylesStylesheet || '').replaceAll('</', '<\\/'); nodes.push({ name: 'style', attrs: { id: 'apos-styles-stylesheet' }, body: [ { - raw: req.data.global.stylesStylesheet || '' + raw: css } ] }); @@ -231,6 +233,8 @@ module.exports = (self, options) => { transform: assetOptions.transform || null }); } + // Prevent an XSS attack even if a styles exploit is found + css = css.replaceAll('</', '<\\/'); return `<style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n` + css + '\n</style>';
packages/apostrophe/test/schemas.js+39 −3 modified@@ -38,6 +38,11 @@ describe('Schemas', function() { name: 'slug', label: 'Slug', type: 'slug' + }, + { + name: 'color', + label: 'Color', + type: 'color' } ]; @@ -1371,26 +1376,57 @@ describe('Schemas', function() { const schema = apos.schema.compose({ addFields: simpleFields }); - assert(schema.length === 4); + assert(schema.length === 5); const input = { name: 'Bob Smith', address: '5017 Awesome Street\nPhiladelphia, PA 19147', irrelevant: 'Irrelevant', - slug: 'This Is Cool' + slug: 'This Is Cool', + color: '#ddeeff' }; const req = apos.task.getReq(); const result = {}; await apos.schema.convert(req, schema, input, result); // no irrelevant or missing fields - assert(_.keys(result).length === 4); + assert(_.keys(result).length === 5); // expected fields came through assert(result.name === input.name); assert(result.address === input.address); + assert.strictEqual(result.color, '#ddeeff'); // default assert(result.variety === undefined); assert(result.slug === 'this-is-cool'); }); + it('should allow well-behaved CSS variable names', async function() { + const schema = apos.schema.compose({ + addFields: simpleFields + }); + const input = { + color: '--somevar' + }; + const req = apos.task.getReq(); + const result = {}; + await apos.schema.convert(req, schema, input, result); + // expected fields came through + assert.strictEqual(result.color, '--somevar'); + }); + + it('should NOT allow malicious CSS variable names', async function() { + const schema = apos.schema.compose({ + addFields: simpleFields + }); + const malicious = '--</style><script>alert("mwahahah")'; + const input = { + color: malicious + }; + const req = apos.task.getReq(); + const result = {}; + await apos.schema.convert(req, schema, input, result); + // Should be blocked + assert.notStrictEqual(result.color, malicious); + }); + it('should update a password if provided', async function() { const schema = apos.schema.compose({ addFields: [
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
4- github.com/apostrophecms/apostrophe/commit/6a89bdb7acdb2e1e9bf1429961a6ba7f99410481nvdPatchWEB
- github.com/apostrophecms/apostrophe/security/advisories/GHSA-97v6-998m-fp4gnvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-97v6-998m-fp4gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33889ghsaADVISORY
News mentions
0No linked articles in our index yet.