From 5c2f14f9dfb6aab35e4e20e5932b457613c8708b Mon Sep 17 00:00:00 2001 From: Vicki League Date: Wed, 24 Jun 2026 13:18:31 -0400 Subject: [PATCH] [PM-38190] Improve sanitization of storybook args helper (#21362) --- .../format-args-for-code-snippet.spec.ts | 106 ++++++++++++++++++ .storybook/format-args-for-code-snippet.ts | 28 ++++- libs/components/jest.config.js | 2 + tsconfig.eslint.json | 1 + 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 .storybook/format-args-for-code-snippet.spec.ts diff --git a/.storybook/format-args-for-code-snippet.spec.ts b/.storybook/format-args-for-code-snippet.spec.ts new file mode 100644 index 00000000000..d34f52920d2 --- /dev/null +++ b/.storybook/format-args-for-code-snippet.spec.ts @@ -0,0 +1,106 @@ +// @storybook/angular ships as ESM that Jest does not transform; mock it with a faithful +// reimplementation of argsToTemplate so we can exercise the function-arg delegation path. +jest.mock("@storybook/angular", () => ({ + argsToTemplate: (args: Record, options: { include?: string[] } = {}) => { + const include = options.include ? new Set(options.include) : null; + return Object.entries(args) + .filter(([key]) => args[key] !== undefined) + .filter(([key]) => (include ? include.has(key) : true)) + .map(([key, value]) => + typeof value === "function" ? `(${key})="${key}($event)"` : `[${key}]="${key}"`, + ) + .join(" "); + }, +})); + +import { formatArgsForCodeSnippet } from "./format-args-for-code-snippet"; + +describe("formatArgsForCodeSnippet", () => { + describe("normal args", () => { + it("renders a string arg as a plain attribute", () => { + expect(formatArgsForCodeSnippet({ buttonType: "secondary" }).trim()).toBe( + 'buttonType="secondary"', + ); + }); + + it("renders a boolean arg as a property binding", () => { + expect(formatArgsForCodeSnippet({ disabled: true }).trim()).toBe('[disabled]="true"'); + }); + + it("renders a number arg as a property binding", () => { + expect(formatArgsForCodeSnippet({ size: 2 }).trim()).toBe('[size]="2"'); + }); + + it("renders an array arg as a property binding", () => { + expect(formatArgsForCodeSnippet({ items: ["a", "b"] }).trim()).toBe("[items]=\"['a', 'b']\""); + }); + + it("skips null and undefined args", () => { + expect(formatArgsForCodeSnippet({ a: null, b: undefined, c: "ok" }).trim()).toBe('c="ok"'); + }); + }); + + describe("value-breakout (XSS) prevention", () => { + it("escapes a string value that tries to break out of the attribute", () => { + const result = formatArgsForCodeSnippet({ + buttonType: 'secondary" onclick="alert(1)" x="', + }); + + // The injected double quotes must be escaped, so no new attribute is created. + expect(result).not.toMatch(/onclick="/); + expect(result).toContain("""); + expect(result).toContain( + 'buttonType="secondary" onclick="alert(1)" x=""', + ); + }); + + it("escapes <, >, and & in string values", () => { + const result = formatArgsForCodeSnippet({ label: ' & "x"' }); + expect(result).toContain('label="<img> & "x""'); + }); + + it("escapes single quotes in array elements so they cannot break the expression", () => { + const result = formatArgsForCodeSnippet({ items: ["a'] + alert(1) + ['"] }); + // The single quotes are backslash-escaped, keeping them inside the string literal. + expect(result).toContain("\\'"); + expect(result).not.toMatch(/\['a'\] \+ alert/); + }); + + it("escapes a double quote in an array element so it cannot break out of the attribute", () => { + const result = formatArgsForCodeSnippet({ items: ['a" onmouseover="alert(1)'] }); + // The injected double quote must be escaped, so no new attribute is created. + expect(result).not.toMatch(/onmouseover="/); + expect(result).toContain("""); + expect(result).toContain("[items]=\"['a" onmouseover="alert(1)']\""); + }); + }); + + describe("key-injection (XSS) prevention", () => { + it("drops an injected on* event-handler key", () => { + const result = formatArgsForCodeSnippet({ onmouseover: "alert(document.domain)" }); + expect(result).not.toContain("onmouseover"); + expect(result).not.toContain("alert"); + }); + + it("drops a string value smuggled under a normally-function key", () => { + const result = formatArgsForCodeSnippet({ onclick: "alert(1)" }); + expect(result).not.toContain("onclick"); + }); + + it("drops keys with non-identifier characters", () => { + const result = formatArgsForCodeSnippet({ 'x">