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
40 changes: 39 additions & 1 deletion packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { describe, it, expect, vi } from "vitest";
import { bundleToSingleHtml } from "./htmlBundler";
import { bundleToSingleHtml, emitRootCompositionVariableStyles } from "./htmlBundler";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";

function makeTempProject(files: Record<string, string>): string {
Expand Down Expand Up @@ -1388,3 +1388,41 @@ describe("bundleToSingleHtml", () => {
}
});
});

/**
* Composition variable values are emitted as CSS declarations inside a `<style>`
* element. `<style>` is a RAW TEXT element: HTML serialization does not escape its
* content and the tokenizer closes it at the first `</style`. An unescaped value could
* therefore close the element and have the remainder parsed as markup.
*/
describe("emitRootCompositionVariableStyles — <style> breakout", () => {
const BREAKOUT = "</style><script>window.__pwned=1</script><style>";

/** Emit into a document, serialize it the way the compilers do, then re-parse. */
function scriptsAfterRoundTrip(variablesByComp: Record<string, Record<string, unknown>>) {
const { document } = parseHTML("<!doctype html><html><head></head><body>x</body></html>");
emitRootCompositionVariableStyles(document, variablesByComp);
const { document: reparsed } = parseHTML(document.toString());
return {
scripts: [...reparsed.querySelectorAll("script")].map((s) => s.textContent ?? ""),
css: [...reparsed.querySelectorAll("style")].map((s) => s.textContent ?? "").join("\n"),
};
}

it("does not let a variable VALUE close the style element", () => {
const { scripts } = scriptsAfterRoundTrip({ "comp-a": { brand: BREAKOUT } });
expect(scripts).toEqual([]);
});

it("keeps the escaped value in the stylesheet as a CSS escape", () => {
const { css, scripts } = scriptsAfterRoundTrip({ "comp-a": { brand: "a<b" } });
expect(scripts).toEqual([]);
expect(css).toContain("a\\3c b");
expect(css).not.toContain("a<b");
});

it("leaves values without '<' untouched", () => {
const { css } = scriptsAfterRoundTrip({ "comp-a": { brand: "#ff0066" } });
expect(css).toContain("#ff0066");
});
});
23 changes: 22 additions & 1 deletion packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,27 @@ export async function bundleToSingleHtml(
return document.toString();
}

/**
* CSS-escape `<` in a variable value.
*
* These rules are emitted into a `<style>` element, and `<style>` is a RAW TEXT
* element: HTML serialization does not escape its content, and the tokenizer
* ends it at the first `</style` regardless of CSS string context. A variable
* value carrying `</style><script>…` would therefore close the element and be
* parsed as markup once the document is serialized (the producer's
* `document.toString()`), turning a value into executable script.
*
* `\3c ` is the CSS escape for `<`. It is valid in every value position —
* including inside an unquoted `url()`, whose grammar allows escape sequences —
* and resolves back to `<`, so rendering is unchanged. The trailing space is
* consumed as part of the escape.
*
* Variable IDs need no equivalent: `cssVariableName` slugifies them.
*/
function cssSafeVariableValue(value: string | number): string {
return String(value).replace(/</g, "\\3c ");
}

/** One stylesheet rule defining primitive composition variables under `selector`. */
function compositionVariablesCssBlock(
variables: Record<string, unknown>,
Expand All @@ -1129,7 +1150,7 @@ function compositionVariablesCssBlock(
const lines: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
lines.push(` ${cssVariableName(id)}: ${String(value)};`);
lines.push(` ${cssVariableName(id)}: ${cssSafeVariableValue(value)};`);
}
}
if (lines.length === 0) return null;
Expand Down