
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: < 4.34.0
Patched in 4.34.0
Details
ApostropheCMS widget styles support project-defined schema fields. If a project defines a free-form string style field with a CSS property and no selector, an editor-controlled value is rendered as an inline CSS declaration. The resulting declaration is then inserted into the widget wrapper's style="..." attribute without HTML-attribute escaping.
An attacker who can edit the widget can include a double quote in the style value, terminate the style attribute, and inject new HTML event-handler attributes. The payload is stored with the widget and executes in the Apostrophe site origin whenever the affected published page is rendered.
This is a sibling of CVE-2026-33889 / GHSA-97v6-998m-fp4g. The earlier issue escaped from a <style> element through a malicious color value. This variant reaches a different sink related to the automatically generated widget wrapper's inline style and class attributes.
The standard string field converter uses launder.string() and length validation. This performs type normalization, not output encoding for an HTML attribute context:
packages/apostrophe/modules/@apostrophecms/schema/lib/addFieldTypes.js:296-333
self.addFieldType({
name: 'string',
// ...
convert(req, field, data, destination) {
destination[field.name] = self.apos.launder.string(data[field.name]);
destination[field.name] = checkStringLength(
destination[field.name],
field.min,
field.max
);
}
});
Quotes therefore remain in a value supplied through a style field.
For a field with property but no selector or media query, renderScopedStyles() selects inline rendering. The generic extractor concatenates the configured property and editor-controlled value:
packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs:171-228
packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs:598-647
rule = `${property}: ${normalized.value}${normalized.unit}`;
// ...
if (isInline) {
return {
css: '',
classes: [ ...storage.classes ],
inline: stringifyRules(storage.styles, true)
};
}
For example, the value below becomes part of a font-family declaration:
serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'
getWidgetAttributes() places the inline CSS into an attribute tuple, then concatenates every tuple into key="value" without encoding &, <, >, ', or ":
packages/apostrophe/modules/@apostrophecms/styles/lib/methods.js:251-322
if (styleParts.length) {
attrs.push(
[ 'style', `${styleParts.join(';')};` ]
);
}
if (options.asObject) {
return Object.fromEntries(attrs);
}
return attrs
.map(([ key, value ]) => `${key}="${value}"`)
.join(' ');
The same raw serializer also makes a project-defined free-form string style with class: true a potential sibling path, because the string is added to classes and later serialized into class="...". The inline-property path was the path dynamically verified.
Styled widgets use getWidgetAttributes() to create a wrapper around the rendered widget:
packages/apostrophe/modules/@apostrophecms/widget-type/index.js:448-457
if (hasStyles && self.options.stylesWrapper !== false) {
const styles = self.apos.styles.prepareWidgetStyles(widget);
const styleTag = self.apos.styles
.getWidgetElements(styles, { scene: req.scene });
const wrapperAttrs = self.apos.styles.getWidgetAttributes(styles);
return `${styleTag}<div${wrapperAttrs ? ' ' + wrapperAttrs : ''}>${markup}</div>`;
}
Consequently, the injected attribute is present on normal public page renders, not only in the editor UI.
The sibling advisory led to a defense in getWidgetElements() that rewrites </ before CSS is inserted into a <style> element:
css = css.replaceAll('</', '<\\/');
This protects the style-element boundary. The vulnerable path above produces inline output instead of css, so it never reaches that replacement.
The verified configuration has all of the following properties:
string.property without a selector, causing inline-style rendering, or uses class: true with an unrestricted string value.Standard range, select, and color presets do not directly provide an unrestricted quote-containing value and were not demonstrated as affected by this variant.
Add a style field similar to the following to a widget module:
module.exports = {
extend: '@apostrophecms/widget-type',
options: {
label: 'Style Attribute Test'
},
styles: {
add: {
customFont: {
type: 'string',
label: 'Custom Font',
property: 'font-family'
}
}
}
};
The absence of selector makes the generated declaration inline.
As a user who may edit this widget, enter the following value in customFont and save the widget:
serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'
Publish the page, or have another user view the affected draft.
The vulnerable data flow can also be reproduced using the real renderer and serializer from a checked-out, dependency-installed repository:
const methodsFactory = require(
'./packages/apostrophe/modules/@apostrophecms/styles/lib/methods.js'
);
(async () => {
const { renderScopedStyles } = await import(
'./packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs'
);
const payload =
'serif" tabindex="0" autofocus ' +
'onfocus="document.body.dataset.inline=\'executed\'';
const styles = renderScopedStyles(
[ {
name: 'customFont',
type: 'string',
property: 'font-family'
} ],
{ customFont: payload }
);
Object.assign(styles, {
styleId: 'audit-inline',
widgetId: 'widget-1'
});
const attrs = methodsFactory({}, {}).getWidgetAttributes(styles);
console.log('inline:', styles.inline);
console.log('attributes:', attrs);
})();
The current renderer produced:
inline: font-family: serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed';
The serializer then produced an attribute string equivalent to:
id="audit-inline"
data-apos-widget-style-wrapper-for="widget-1"
data-apos-widget-style-classes=""
style="font-family: serif" tabindex="0" autofocus
onfocus="document.body.dataset.inline='executed';"
The attacker-controlled quote terminates the style attribute. tabindex, autofocus, and onfocus become separate live attributes.
When the generated wrapper was loaded in Chrome 153, autofocus triggered the injected handler automatically. The DOM changed to:
<body data-inline="executed">
<div id="audit-inline" tabindex="0" autofocus="" onfocus="..."></div>
</body>
The safe behavior would be for the quote to appear as " inside the style attribute, with no injected attributes and no change to document.body.dataset.
The string branch of getWidgetAttributes() should encode values for a double-quoted HTML attribute context before concatenation. At minimum, encode &, <, >, ", and ' using a centralized Apostrophe HTML encoder.
Conceptually:
if (options.asObject) {
return Object.fromEntries(attrs);
}
return attrs
.map(([ key, value ]) => {
return `${key}="${escapeHtmlAttribute(String(value))}"`;
})
.join(' ');
The structured asObject branch should remain structured so that its eventual template/DOM renderer can perform context-appropriate encoding.
Output encoding is the primary fix. As defense in depth:
class: true, accept only individual valid class-name tokens rather than an unrestricted string.EPSS, exploit probability
Low0.30%
estimated chance of real-world exploitation in the next 30 days, higher than 21.8% of every CVE FIRST.org scores
Refreshed 10/21/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.