Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/apps-website/src/components/Seo.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { htmlSafeJson } from "@decocms/blocks/sdk/htmlSafe";
import type { ImageWidget, OGType } from "../types";
import { stripHTML } from "../utils/html";

Expand Down Expand Up @@ -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<string, unknown>),
}),
Expand Down
7 changes: 5 additions & 2 deletions packages/apps-website/src/components/Theme.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cssSafe } from "@decocms/blocks/sdk/htmlSafe";
import { useId } from "react";
import type { Font, Variable } from "../types";

Expand All @@ -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 `</style>` and break out of the inline <style> below.
const vars = [{ name: "--font-family", value: family }, ...variables]
.map(({ name, value }) => `${name}: ${value}`)
.map(({ name, value }) => `${cssSafe(name)}: ${cssSafe(value)}`)
.join(";");

const css = `* {${vars}}`;
Expand All @@ -30,7 +33,7 @@ function Theme({ fonts = [], variables = [], colorScheme }: Props) {
<>
{fonts?.map(({ styleSheet }, idx) =>
styleSheet ? (
<style key={idx} type="text/css" dangerouslySetInnerHTML={{ __html: styleSheet }} />
<style key={idx} type="text/css" dangerouslySetInnerHTML={{ __html: cssSafe(styleSheet) }} />
) : null,
)}
{html && (
Expand Down
35 changes: 35 additions & 0 deletions packages/apps-website/src/components/xssSinks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import Seo from "./Seo";
import Theme from "./Theme";

// Regression: these components emit CMS-configurable values into inline
// <script>/<style> bodies. Attacker-influenceable content must never break out.

const SCRIPT_INJECT = "<script>alert(document.domain)</script>";

describe("Seo — JSON-LD script sink", () => {
it("does not let a jsonLDs value break out of the ld+json script", () => {
const html = renderToStaticMarkup(
<Seo jsonLDs={[{ name: `</script>${SCRIPT_INJECT}` }]} />,
);
expect(html).not.toContain(SCRIPT_INJECT);
expect(html).not.toContain("</script><script>");
});
});

describe("Theme — <style> sink", () => {
it("does not let a design-token value break out of the <style> tag", () => {
const html = renderToStaticMarkup(
<Theme variables={[{ name: "--x", value: `red}</style>${SCRIPT_INJECT}` }]} />,
);
expect(html).not.toContain(SCRIPT_INJECT);
// The injected closing tag must be escaped; the only </style> allowed is the
// component's own real terminator, never one followed by injected markup.
expect(html).not.toContain("</style><script>");
});
});

// Note: GoogleTagManager is NOT tested here — its trackingId flows through
// `new URL(...).href`, which percent-encodes quotes/`<`/`>` (verified: `'` -> %27),
// so it cannot break out of the inline JS string. GTAG additionally sanitizes.
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ export default function Video({
}
`;

files["src/components/ui/Seo.tsx"] = `export interface Props {
files["src/components/ui/Seo.tsx"] = `import { htmlSafeJson } from "@decocms/blocks/sdk/htmlSafe";

export interface Props {
title?: string;
description?: string;
canonical?: string;
Expand All @@ -135,7 +137,7 @@ export default function Seo({ jsonLDs }: Props) {
<script
key={i}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLD) }}
dangerouslySetInnerHTML={{ __html: htmlSafeJson(jsonLD) }}
/>
))}
</>
Expand Down
1 change: 1 addition & 0 deletions packages/blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"./sdk/fetchTimeout": "./src/sdk/fetchTimeout.ts",
"./sdk/experiments": "./src/sdk/experiments.ts",
"./sdk/flags": "./src/sdk/flags.ts",
"./sdk/htmlSafe": "./src/sdk/htmlSafe.ts",
"./sdk/http": "./src/sdk/http.ts",
"./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
"./sdk/invoke": "./src/sdk/invoke.ts",
Expand Down
4 changes: 3 additions & 1 deletion packages/blocks/src/hooks/JsonLd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
// from @decocms/apps-commerce/types)
// -------------------------------------------------------------------------

import { htmlSafeJson } from "../sdk/htmlSafe";

interface JsonLdOffer {
price?: number;
priceCurrency?: string;
Expand Down Expand Up @@ -115,7 +117,7 @@ export interface JsonLdBreadcrumbList {

function JsonLdScript({ data }: { data: unknown }) {
return (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: htmlSafeJson(data) }} />
);
}

Expand Down
3 changes: 2 additions & 1 deletion packages/blocks/src/hooks/LiveControls.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect } from "react";
import { htmlSafeJson } from "../sdk/htmlSafe";

interface LiveControlsProps {
site?: string;
Expand Down Expand Up @@ -36,7 +37,7 @@ export function LiveControls({ site, page, flags }: LiveControlsProps) {
id="__DECO_STATE"
type="application/json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
__html: htmlSafeJson({
page: page || {},
site: { name: site || "storefront" },
flags: flags || [],
Expand Down
30 changes: 30 additions & 0 deletions packages/blocks/src/hooks/xssSinks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ProductJsonLd } from "./JsonLd";
import { LiveControls } from "./LiveControls";

// Regression: these components emit attacker-influenceable data into inline
// <script> bodies. A value containing `</script>` 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 = "</script><script>alert(document.domain)</script>";
const INJECTED = "<script>alert(document.domain)</script>";

describe("ProductJsonLd — JSON-LD script sink", () => {
it("does not let a product name break out of the ld+json script", () => {
const html = renderToStaticMarkup(<ProductJsonLd product={{ name: BREAKOUT }} />);
expect(html).not.toContain(INJECTED);
expect(html).not.toContain("</script><script>");
});
});

describe("LiveControls — __DECO_STATE script sink", () => {
it("does not let a page pathTemplate break out of the state script", () => {
const html = renderToStaticMarkup(
<LiveControls site="s" page={{ id: "p", pathTemplate: BREAKOUT }} />,
);
expect(html).not.toContain(INJECTED);
expect(html).not.toContain("</script><script>");
});
});
66 changes: 66 additions & 0 deletions packages/blocks/src/sdk/htmlSafe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { cssSafe, htmlSafeJson, jsString } from "./htmlSafe";

// These sinks emit into <script>/<style> via dangerouslySetInnerHTML, where the
// HTML parser — not the JS/JSON grammar — decides where the element ends. The
// only defense is escaping the characters that can terminate the element or its
// string context. Bare JSON.stringify does NOT do this (it escapes JSON
// metacharacters, not `<`), which is the whole bug class.

const BREAKOUT = "</script><script>alert(document.domain)</script>";
const LS = String.fromCharCode(0x2028); // line separator — breaks inline scripts
const PS = String.fromCharCode(0x2029); // paragraph separator

describe("htmlSafeJson — JSON embedded in <script>", () => {
it("neutralizes a </script> 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("</script>");
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 </script> breakout", () => {
const emitted = `posthog.init('${jsString(BREAKOUT)}')`;
expect(emitted).not.toContain("</script>");
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 <style> body", () => {
it("prevents a </style> breakout", () => {
const out = cssSafe("red}</style><script>alert(1)</script>");
expect(out).not.toContain("</style>");
expect(out).not.toContain("<");
expect(out).not.toContain(">");
});

it("leaves a benign CSS value intact", () => {
expect(cssSafe("#fff")).toBe("#fff");
});
});
62 changes: 62 additions & 0 deletions packages/blocks/src/sdk/htmlSafe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Context-aware escaping for values interpolated into inline `<script>` /
* `<style>` bodies via `dangerouslySetInnerHTML`.
*
* Why this exists: inside a `<script>`/`<style>` element the HTML parser — not
* the JS/JSON/CSS grammar — decides where the element ends. It closes at the
* first literal `</script>` / `</style>` regardless of quoting or JSON context.
* `JSON.stringify` escapes JSON metacharacters but NOT `<`, so a string value
* containing `</script>` 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 `<script>`
* body. `<`, `>`, `&` and the JS line terminators U+2028/U+2029 are emitted as
* their `\uXXXX` JSON escapes — still valid JSON that parses back to the same
* value, but with no raw `</script>` (or `<!--`) able to reach the HTML stream.
*/
export function htmlSafeJson(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(LINE_SEP, "\\u2028")
.replace(PARA_SEP, "\\u2029");
}

/**
* Escape a value for interpolation into a `<style>` body. Neutralizes a
* `</style>` tag breakout by CSS-escaping `<`/`>` (`\3c `/`\3e `) — both are
* invalid in a real CSS value/selector, so escaping never changes legit output.
*/
export function cssSafe(css: string): string {
return css.replace(/</g, "\\3c ").replace(/>/g, "\\3e ");
}

/**
* Escape a value for interpolation inside a single- or double-quoted JS string
* literal in an inline `<script>` (e.g. `foo('${jsString(x)}')`). Neutralizes
* both string-literal breakout (quotes/backslash/newlines) and tag breakout
* (`<` -> `<`, so `</script>` can never appear).
*/
export function jsString(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/`/g, "\\`")
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(LINE_SEP, "\\u2028")
.replace(PARA_SEP, "\\u2029");
}
36 changes: 36 additions & 0 deletions packages/blocks/src/sdk/scriptSinkGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

// Class guard: no inline <script>/<style> sink may serialize a value with a bare
// `JSON.stringify(...)` — that does not escape `</script>` and reintroduces the
// XSS class. Use htmlSafeJson/jsString/cssSafe (@decocms/blocks/sdk/htmlSafe).
// This test fails the build if a new sink regresses, anywhere in packages/*.

const REPO_ROOT = process.cwd();
const PACKAGES = join(REPO_ROOT, "packages");

function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (name === "node_modules" || name === "dist") continue;
const full = join(dir, name);
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name)) out.push(full);
}
return out;
}

// Match a `dangerouslySetInnerHTML` whose __html expression uses JSON.stringify,
// tolerating whitespace/newlines between the pieces.
const SINK_RE = /dangerouslySetInnerHTML\s*=\s*\{\{[\s\S]{0,200}?__html\s*:\s*[\s\S]{0,80}?JSON\.stringify/;

describe("no bare JSON.stringify in a <script>/<style> sink", () => {
it("every dangerouslySetInnerHTML uses the htmlSafe helpers, not JSON.stringify", () => {
const offenders: string[] = [];
for (const file of walk(PACKAGES)) {
const src = readFileSync(file, "utf8");
if (SINK_RE.test(src)) offenders.push(file.slice(REPO_ROOT.length + 1));
}
expect(offenders).toEqual([]);
});
});
Loading