GitHub Advisoryadvisory · npm

GHSA-7jg5-frgc-c67w ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization

MEDIUM Published 2026-10-21 Modified 2026-10-21 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