diff --git a/packages/apps-website/src/components/Seo.tsx b/packages/apps-website/src/components/Seo.tsx index 50d8c512..75aa4ebd 100644 --- a/packages/apps-website/src/components/Seo.tsx +++ b/packages/apps-website/src/components/Seo.tsx @@ -1,3 +1,4 @@ +import { htmlSafeJson } from "@decocms/blocks/sdk/htmlSafe"; import type { ImageWidget, OGType } from "../types"; import { stripHTML } from "../utils/html"; @@ -125,7 +126,7 @@ function Seo({ key={idx} type="application/ld+json" dangerouslySetInnerHTML={{ - __html: JSON.stringify({ + __html: htmlSafeJson({ "@context": "https://schema.org", ...(json as Record), }), diff --git a/packages/apps-website/src/components/Theme.tsx b/packages/apps-website/src/components/Theme.tsx index 3e658a4b..91333aad 100644 --- a/packages/apps-website/src/components/Theme.tsx +++ b/packages/apps-website/src/components/Theme.tsx @@ -1,3 +1,4 @@ +import { cssSafe } from "@decocms/blocks/sdk/htmlSafe"; import { useId } from "react"; import type { Font, Variable } from "../types"; @@ -19,8 +20,10 @@ function Theme({ fonts = [], variables = [], colorScheme }: Props) { const family = fonts.reduce((acc, { family }) => (acc ? `${acc}, ${family}` : family), ""); + // cssSafe on each token name/value so an attacker-influenceable design token + // can't emit `` and break out of the inline ${SCRIPT_INJECT}` }]} />, + ); + expect(html).not.toContain(SCRIPT_INJECT); + // The injected closing tag must be escaped; the only allowed is the + // component's own real terminator, never one followed by injected markup. + expect(html).not.toContain("` must never break out of the +// tag. Asserting on the rendered HTML is the only faithful check — tsc and the +// component's types don't catch it (React does not escape dangerouslySetInnerHTML). + +const BREAKOUT = ""; +const INJECTED = ""; + +describe("ProductJsonLd — JSON-LD script sink", () => { + it("does not let a product name break out of the ld+json script", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain(INJECTED); + expect(html).not.toContain(""; +const LS = String.fromCharCode(0x2028); // line separator — breaks inline scripts +const PS = String.fromCharCode(0x2029); // paragraph separator + +describe("htmlSafeJson — JSON embedded in breakout inside a string value", () => { + const out = htmlSafeJson({ name: BREAKOUT }); + // The literal tag terminator must never survive into the HTML stream. + expect(out).not.toContain(""); + expect(out).not.toContain("<"); + expect(out).not.toContain(">"); + }); + + it("stays valid JSON that parses back to the original value", () => { + const data = { name: BREAKOUT, n: 1, nested: { u: `a${LS}b` } }; + expect(JSON.parse(htmlSafeJson(data))).toEqual(data); + }); + + it("escapes the line/paragraph separators that break inline scripts", () => { + const out = htmlSafeJson({ s: `a${LS}b${PS}c` }); + expect(out).not.toContain(LS); + expect(out).not.toContain(PS); + }); +}); + +describe("jsString — value interpolated into a single-quoted JS string", () => { + it("neutralizes a breakout", () => { + const emitted = `posthog.init('${jsString(BREAKOUT)}')`; + expect(emitted).not.toContain(""); + expect(emitted).not.toContain("<"); + }); + + it("neutralizes a single-quote string-breakout", () => { + const esc = jsString("');alert(1);('"); + // The security invariant: no single quote may appear UN-escaped, so the + // payload can never close the surrounding '...' literal early. + expect(esc).not.toMatch(/(^|[^\\])'/); + expect(esc).toContain("\\'"); + }); + + it("leaves a benign value readable", () => { + expect(jsString("phc_abc123")).toBe("phc_abc123"); + }); +}); + +describe("cssSafe — value interpolated into a breakout", () => { + const out = cssSafe("red}"); + expect(out).not.toContain(""); + expect(out).not.toContain("<"); + expect(out).not.toContain(">"); + }); + + it("leaves a benign CSS value intact", () => { + expect(cssSafe("#fff")).toBe("#fff"); + }); +}); diff --git a/packages/blocks/src/sdk/htmlSafe.ts b/packages/blocks/src/sdk/htmlSafe.ts new file mode 100644 index 00000000..efafa04d --- /dev/null +++ b/packages/blocks/src/sdk/htmlSafe.ts @@ -0,0 +1,62 @@ +/** + * Context-aware escaping for values interpolated into inline `` / `` regardless of quoting or JSON context. + * `JSON.stringify` escapes JSON metacharacters but NOT `<`, so a string value + * containing `` breaks out of the tag and injects markup. React does + * not escape inside `dangerouslySetInnerHTML`. These helpers close that class: + * always run untrusted (or possibly-untrusted) values through the matching + * helper for the surrounding context, never bare `JSON.stringify`/interpolation. + */ + +// Built via RegExp() so the source file never contains a raw U+2028/U+2029 +// byte — those are JS line terminators and would break the parser here. +const LINE_SEP = new RegExp("\\u2028", "g"); +const PARA_SEP = new RegExp("\\u2029", "g"); + +/** + * Serialize a value to JSON that is safe to embed directly in a `` (or `