medium

CVE-2026-112958

npm · apostrophe

Summary

ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization

Severity
medium
CVSS
5.4
EPSS
0.3% (p23)
CWE
CWE-79
Also known as
GHSA-7jg5-frgc-c67w
Published
2026-10-21
Updated
2026-10-21

Advisory details

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

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

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'

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

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

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:

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:

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:

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:

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:

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:

inline: font-family: serif" tabindex="0" autofocus onfocus="document.body.dataset.inline='executed';

The serializer then produced an attrib

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.