GitHub Advisoryadvisory · npm
GHSA-7jg5-frgc-c67w ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization
MEDIUM
Official page ↗
- Aliases
- CVE-2026-112958
- Ecosystem
- npm
- Packages
- apostrophe
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N
- Weaknesses
- CWE-79
- Reviewed
- 2026-10-21
Affected packages
• npm apostrophe: < 4.34.0 → fixed in 4.34.0
Description
Summary
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 `` 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.
Details
Root Cause 1: Free-form string style values retain HTML metacharacters
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`
```javascript
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.
Root Cause 2: The value is converted into inline CSS without value validation
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`
```javascript
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:
```text
serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'
```
Root Cause 3: Attribute values are serialized without HTML escaping
`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`
```javascript
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.
Root Cause 4: The unsafe string is used by the automatic public wrapper
Styled widgets use `getWidgetAttributes()` to create a wrapper around the rendered widget:
`packages/apostrophe/modules/@apostrophecms/widget-type/index.js:448-457`
```javascript
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}${markup}`;
}
```
Consequently, the injected attribute is present on normal public page renders, not only in the editor UI.
Why the CVE-2026-33889 defense does not prevent this variant
The sibling advisory led to a defense in `getWidgetElements()` that rewrites `` element:
```javascript
css = css.replaceAll(' {
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);
})();
```
Step 4: Observe the unsafe HTML
The current renderer produced:
```text
inline: font-family: serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed';
```
The serializer then produced an attribute string equivalent to:
```html
id="audit-inl…
Related
Alias · CVE (1)
- CVE-2026-112958 ApostropheCMS is an open-source Node.js content management system. vulnerability
Weakness · CWE (1)
- CWE-79 Improper Neutralization of Input During Web Page Generation weakness
References
- https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-7jg5-frgc-c67w web
- https://nvd.nist.gov/vuln/detail/CVE-2026-112958 advisory
- https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498 web
- https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea web
- https://github.com/apostrophecms/apostrophe package
- https://github.com/apostrophecms/apostrophe/releases/tag/[email protected] web