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

feat(web+appview): CSS sanitization for theme cssOverrides (ATB-62) (#92)

* feat(web+appview): CSS sanitization for theme cssOverrides (ATB-62)

Add @atbb/css-sanitizer workspace package (css-tree v2 AST-based) that
strips dangerous CSS constructs — @import, external url(), @font-face
with external src, expression(), -moz-binding, behavior, data: URIs —
while preserving safe structural overrides.

- appview: sanitize cssOverrides at write time (POST + PUT /api/admin/themes)
and log any stripped constructs as structured warnings
- web: replace inline stub sanitizeCss with the real package; enable the
CSS overrides textarea in the theme editor (was disabled pending ATB-62)

* fix(css-sanitizer): address PR review security and quality issues

Critical:
- Strip </style> sequences from generated output to prevent HTML parser
breakout when CSS is injected via dangerouslySetInnerHTML (XSS regression)
- Fail closed on css-tree onParseError: Raw nodes from error recovery bypass
walker checks, so discard entire output when any parse error occurs
- Wrap sanitizeCssOverrides calls in dedicated try-catch in POST and PUT
theme handlers (separate from PDS write block per CLAUDE.md granularity rule)
- Add try-catch around sanitizeCss calls in BaseLayout with empty fallback
so a css-tree bug doesn't 500 every page for all users

Security:
- Sanitize cssOverrides in POST /api/admin/themes/:rkey/duplicate so
pre-sanitization records don't propagate dangerous CSS via duplication
- Move warning push after list.remove() so audit log only says "Stripped X"
when the node was actually removed (not before the null-check)
- Fix onParseError type signature: (error: SyntaxError) => void

Quality:
- Replace JSON.stringify(warnings) with warnings in structured logger calls
- Update Bruno Create Theme.bru: remove stale ATB-62 placeholder text
- Add integration tests: dangerous CSS stripped in POST and PUT theme handlers
- Fix duplicate test expectation: sanitizer now runs on duplication (compact form)
- Fix </style> test: split into fail-closed test and string-literal stripping test

authored by

Malpercio and committed by
GitHub
fb9491c9 6cbe2a4d

+803 -38
+1
apps/appview/package.json
··· 19 19 }, 20 20 "dependencies": { 21 21 "@atbb/atproto": "workspace:*", 22 + "@atbb/css-sanitizer": "workspace:*", 22 23 "@atbb/logger": "workspace:*", 23 24 "@atbb/db": "workspace:*", 24 25 "@atbb/lexicon": "workspace:*",
+41 -3
apps/appview/src/routes/__tests__/admin.test.ts
··· 2537 2537 }); 2538 2538 expect(res.status).toBe(201); 2539 2539 const call = mockPutRecord.mock.calls[0][0]; 2540 - expect(call.record.cssOverrides).toBe(".card { border-radius: 4px; }"); 2540 + // Sanitizer reformats CSS to compact form (no extra spaces) 2541 + expect(call.record.cssOverrides).toBe(".card{border-radius:4px}"); 2541 2542 expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 2543 + }); 2544 + 2545 + it("strips dangerous CSS constructs from cssOverrides before PDS write", async () => { 2546 + const res = await app.request("/api/admin/themes", { 2547 + method: "POST", 2548 + headers: { "Content-Type": "application/json" }, 2549 + body: JSON.stringify({ 2550 + name: "Dangerous Theme", 2551 + colorScheme: "light", 2552 + tokens: { "color-bg": "#ffffff" }, 2553 + cssOverrides: '@import "https://evil.com/steal.css"; .ok { color: red; }', 2554 + }), 2555 + }); 2556 + expect(res.status).toBe(201); 2557 + const call = mockPutRecord.mock.calls[0][0]; 2558 + expect(call.record.cssOverrides).not.toContain("@import"); 2559 + expect(call.record.cssOverrides).not.toContain("evil.com"); 2560 + expect(call.record.cssOverrides).toContain("color:red"); 2542 2561 }); 2543 2562 2544 2563 it("returns 400 when name is missing", async () => { ··· 2751 2770 }); 2752 2771 expect(res.status).toBe(200); 2753 2772 const call = mockPutRecord.mock.calls[0][0]; 2754 - expect(call.record.cssOverrides).toBe(".existing { color: red; }"); 2773 + // Sanitizer reformats CSS to compact form (no extra spaces) 2774 + expect(call.record.cssOverrides).toBe(".existing{color:red}"); 2775 + }); 2776 + 2777 + it("strips dangerous CSS constructs from cssOverrides before PDS write on update", async () => { 2778 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2779 + method: "PUT", 2780 + headers: { "Content-Type": "application/json" }, 2781 + body: JSON.stringify({ 2782 + name: "Updated Theme", 2783 + colorScheme: "light", 2784 + tokens: { "color-bg": "#f0f0f0" }, 2785 + cssOverrides: 'body { background: url("https://evil.com/track.gif"); color: blue; }', 2786 + }), 2787 + }); 2788 + expect(res.status).toBe(200); 2789 + const call = mockPutRecord.mock.calls[0][0]; 2790 + expect(call.record.cssOverrides).not.toContain("evil.com"); 2791 + expect(call.record.cssOverrides).toContain("color:blue"); 2755 2792 }); 2756 2793 2757 2794 it("preserves existing fontUrls when not provided in request body", async () => { ··· 3127 3164 expect(res.status).toBe(201); 3128 3165 expect(mockPutRecord).toHaveBeenCalledOnce(); 3129 3166 const putCall = mockPutRecord.mock.calls[0][0]; 3130 - expect(putCall.record.cssOverrides).toBe("body { font-size: 18px; }"); 3167 + // Sanitizer reformats CSS to compact form on duplication 3168 + expect(putCall.record.cssOverrides).toBe("body{font-size:18px}"); 3131 3169 expect(putCall.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Roboto"]); 3132 3170 expect(putCall.record.name).toBe("Custom Theme (Copy)"); 3133 3171 });
+75 -3
apps/appview/src/routes/admin.ts
··· 15 15 } from "../lib/route-errors.js"; 16 16 import { TID } from "@atproto/common-web"; 17 17 import { parseBigIntParam, serializeBigInt, serializeDate } from "./helpers.js"; 18 + import { sanitizeCssOverrides } from "@atbb/css-sanitizer"; 18 19 19 20 export function createAdminRoutes(ctx: AppContext) { 20 21 const app = new Hono<{ Variables: Variables }>(); ··· 1066 1067 } 1067 1068 } 1068 1069 1070 + // Sanitize cssOverrides before writing to PDS. In its own try-catch 1071 + // because sanitization failure has different semantics than a PDS write failure. 1072 + let sanitizedCssOverrides: string | undefined; 1073 + if (typeof cssOverrides === "string") { 1074 + try { 1075 + const { css, warnings } = sanitizeCssOverrides(cssOverrides); 1076 + if (warnings.length > 0) { 1077 + ctx.logger.warn("Stripped dangerous CSS constructs from theme on create", { 1078 + operation: "POST /api/admin/themes", 1079 + warnings, 1080 + }); 1081 + } 1082 + sanitizedCssOverrides = css; 1083 + } catch (error) { 1084 + if (isProgrammingError(error)) throw error; 1085 + ctx.logger.error("CSS sanitization failed unexpectedly on create", { 1086 + operation: "POST /api/admin/themes", 1087 + error: error instanceof Error ? error.message : String(error), 1088 + }); 1089 + return c.json({ error: "Failed to process CSS overrides" }, 500); 1090 + } 1091 + } 1092 + 1069 1093 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 1070 1094 if (agentError) return agentError; 1071 1095 ··· 1082 1106 name: name.trim(), 1083 1107 colorScheme, 1084 1108 tokens, 1085 - ...(typeof cssOverrides === "string" && { cssOverrides }), 1109 + ...(typeof sanitizedCssOverrides === "string" && { cssOverrides: sanitizedCssOverrides }), 1086 1110 ...(Array.isArray(fontUrls) && { fontUrls }), 1087 1111 createdAt: now, 1088 1112 }, ··· 1170 1194 1171 1195 // putRecord is a full replacement — fall back to existing values for 1172 1196 // optional fields not provided in the request body, to avoid data loss. 1173 - const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 1197 + const rawCssOverrides = 1198 + typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 1199 + let resolvedCssOverrides: string | null | undefined = rawCssOverrides; 1200 + if (rawCssOverrides != null) { 1201 + try { 1202 + const { css, warnings } = sanitizeCssOverrides(rawCssOverrides); 1203 + if (warnings.length > 0) { 1204 + ctx.logger.warn("Stripped dangerous CSS constructs from theme on update", { 1205 + operation: "PUT /api/admin/themes/:rkey", 1206 + themeRkey, 1207 + warnings, 1208 + }); 1209 + } 1210 + resolvedCssOverrides = css; 1211 + } catch (error) { 1212 + if (isProgrammingError(error)) throw error; 1213 + ctx.logger.error("CSS sanitization failed unexpectedly on update", { 1214 + operation: "PUT /api/admin/themes/:rkey", 1215 + themeRkey, 1216 + error: error instanceof Error ? error.message : String(error), 1217 + }); 1218 + return c.json({ error: "Failed to process CSS overrides" }, 500); 1219 + } 1220 + } 1174 1221 const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 1175 1222 1176 1223 try { ··· 1332 1379 const newName = `${source.name} (Copy)`; 1333 1380 const now = new Date().toISOString(); 1334 1381 1382 + // Sanitize cssOverrides from source before writing to PDS so any 1383 + // pre-sanitization records don't propagate dangerous CSS via duplication. 1384 + let duplicateCssOverrides: string | null = null; 1385 + if (source.cssOverrides != null) { 1386 + try { 1387 + const { css, warnings } = sanitizeCssOverrides(source.cssOverrides); 1388 + if (warnings.length > 0) { 1389 + ctx.logger.warn("Stripped dangerous CSS constructs from theme on duplicate", { 1390 + operation: "POST /api/admin/themes/:rkey/duplicate", 1391 + sourceRkey, 1392 + warnings, 1393 + }); 1394 + } 1395 + duplicateCssOverrides = css; 1396 + } catch (error) { 1397 + if (isProgrammingError(error)) throw error; 1398 + ctx.logger.error("CSS sanitization failed unexpectedly on duplicate", { 1399 + operation: "POST /api/admin/themes/:rkey/duplicate", 1400 + sourceRkey, 1401 + error: error instanceof Error ? error.message : String(error), 1402 + }); 1403 + return c.json({ error: "Failed to process CSS overrides" }, 500); 1404 + } 1405 + } 1406 + 1335 1407 try { 1336 1408 const result = await agent.com.atproto.repo.putRecord({ 1337 1409 repo: ctx.config.forumDid, ··· 1342 1414 name: newName, 1343 1415 colorScheme: source.colorScheme, 1344 1416 tokens: source.tokens, 1345 - ...(source.cssOverrides != null && { cssOverrides: source.cssOverrides }), 1417 + ...(duplicateCssOverrides != null && { cssOverrides: duplicateCssOverrides }), 1346 1418 ...(source.fontUrls != null && { fontUrls: source.fontUrls }), 1347 1419 createdAt: now, 1348 1420 },
+1
apps/web/package.json
··· 13 13 "clean": "rm -rf dist" 14 14 }, 15 15 "dependencies": { 16 + "@atbb/css-sanitizer": "workspace:*", 16 17 "@atbb/logger": "workspace:*", 17 18 "@hono/node-server": "^1.14.0", 18 19 "hono": "^4.7.0"
+7 -3
apps/web/src/layouts/__tests__/base.test.tsx
··· 16 16 it("injects neobrutal tokens as :root CSS custom properties", async () => { 17 17 const res = await app.request("/"); 18 18 const html = await res.text(); 19 - expect(html).toContain(":root {"); 19 + // css-tree generates compact CSS (no space before brace) 20 + expect(html).toContain(":root{"); 20 21 expect(html).toContain("--color-bg:"); 21 22 expect(html).toContain("--color-primary:"); 22 23 }); ··· 92 93 ); 93 94 const res = await overridesApp.request("/"); 94 95 const html = await res.text(); 95 - expect(html).toContain(".card { border: 2px solid black; }"); 96 + // css-tree generates compact CSS — check for key selectors and properties 97 + expect(html).toContain(".card{"); 98 + expect(html).toContain("border:2px solid black"); 96 99 }); 97 100 98 101 it("does not render Google Fonts preconnect tags when fontUrls is null", async () => { ··· 135 138 // The only <style> tag should be the :root block — no second style tag for overrides 136 139 const styleTagMatches = html.match(/<style/g); 137 140 expect(styleTagMatches).toHaveLength(1); 138 - expect(html).toContain(":root {"); 141 + // css-tree generates compact CSS (no space before brace) 142 + expect(html).toContain(":root{"); 139 143 }); 140 144 141 145 describe("auth-aware navigation", () => {
+25 -13
apps/web/src/layouts/base.tsx
··· 1 1 import type { FC, PropsWithChildren } from "hono/jsx"; 2 2 import { tokensToCss } from "../lib/theme.js"; 3 + import { sanitizeCss } from "@atbb/css-sanitizer"; 3 4 import type { ResolvedTheme } from "../lib/theme-resolution.js"; 4 5 import type { WebSession } from "../lib/session.js"; 5 - 6 - function sanitizeCss(css: string): string { 7 - return css.replace(/<\/style/gi, ""); 8 - } 9 6 10 7 const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 11 8 <> ··· 34 31 }> 35 32 > = (props) => { 36 33 const { auth, resolvedTheme } = props; 34 + 35 + let rootCss = ""; 36 + try { 37 + rootCss = sanitizeCss(`:root { ${tokensToCss(resolvedTheme.tokens)} }`); 38 + } catch (err) { 39 + console.error("Failed to sanitize root CSS tokens — rendering without tokens", { 40 + error: String(err), 41 + }); 42 + } 43 + 44 + let overridesCss: string | null = null; 45 + if (resolvedTheme.cssOverrides) { 46 + try { 47 + overridesCss = sanitizeCss(resolvedTheme.cssOverrides); 48 + } catch (err) { 49 + console.error("Failed to sanitize CSS overrides — rendering without overrides", { 50 + error: String(err), 51 + }); 52 + } 53 + } 54 + 37 55 return ( 38 56 <html lang="en"> 39 57 <head> ··· 41 59 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 42 60 <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 43 61 <title>{props.title ?? "atBB Forum"}</title> 44 - <style 45 - dangerouslySetInnerHTML={{ 46 - __html: sanitizeCss(`:root { ${tokensToCss(resolvedTheme.tokens)} }`), 47 - }} 48 - /> 49 - {resolvedTheme.cssOverrides && ( 50 - <style 51 - dangerouslySetInnerHTML={{ __html: sanitizeCss(resolvedTheme.cssOverrides) }} 52 - /> 62 + <style dangerouslySetInnerHTML={{ __html: rootCss }} /> 63 + {overridesCss && ( 64 + <style dangerouslySetInnerHTML={{ __html: overridesCss }} /> 53 65 )} 54 66 {resolvedTheme.fontUrls && resolvedTheme.fontUrls.length > 0 && (() => { 55 67 const safeFontUrls = resolvedTheme.fontUrls!.filter((url) => url.startsWith("https://"));
+6 -4
apps/web/src/routes/__tests__/admin-themes.test.tsx
··· 172 172 expect(html).toContain("Something went wrong"); 173 173 }); 174 174 175 - // ── CSS overrides field is disabled ───────────────────────────────────── 175 + // ── CSS overrides field is enabled (ATB-62 implemented) ───────────────── 176 176 177 - it("renders CSS overrides field as disabled (awaiting ATB-62)", async () => { 177 + it("renders CSS overrides textarea with correct name and no disabled attribute", async () => { 178 178 setupAuthenticatedSession([MANAGE_THEMES]); 179 179 mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 180 180 const routes = await loadThemeRoutes(); ··· 183 183 }); 184 184 expect(res.status).toBe(200); 185 185 const html = await res.text(); 186 - // Both attributes must be on the same element 187 - expect(html).toMatch(/name="css-overrides"[^>]*disabled|disabled[^>]*name="css-overrides"/); 186 + // Field must use the correct name for form submission 187 + expect(html).toContain('name="cssOverrides"'); 188 + // Field must NOT be disabled 189 + expect(html).not.toMatch(/name="cssOverrides"[^>]*disabled|disabled[^>]*name="cssOverrides"/); 188 190 }); 189 191 }); 190 192
+9 -10
apps/web/src/routes/admin-themes.tsx
··· 674 674 isColor={false} 675 675 /> 676 676 677 - {/* CSS overrides — disabled until ATB-62 */} 677 + {/* CSS overrides */} 678 678 <fieldset class="token-group"> 679 679 <legend>CSS Overrides</legend> 680 680 <div class="token-input"> 681 - <label for="css-overrides"> 682 - Custom CSS{" "} 683 - <span class="form-hint">(disabled — CSS sanitization not yet implemented)</span> 684 - </label> 681 + <label for="css-overrides">Custom CSS</label> 685 682 <textarea 686 683 id="css-overrides" 687 - name="css-overrides" 684 + name="cssOverrides" 688 685 rows={6} 689 - disabled 690 686 aria-describedby="css-overrides-hint" 691 - placeholder="/* Will be enabled in ATB-62 */" 687 + placeholder="/* Structural overrides beyond what design tokens allow */" 692 688 > 693 689 {theme.cssOverrides ?? ""} 694 690 </textarea> 695 691 <p id="css-overrides-hint" class="form-hint"> 696 - Raw CSS overrides will be available after CSS sanitization is implemented (ATB-62). 692 + Raw CSS for structural changes. Dangerous constructs (external 693 + URLs, @import, expression()) are stripped automatically on save. 697 694 </p> 698 695 </div> 699 696 </fieldset> ··· 1049 1046 .split("\n") 1050 1047 .map((u) => u.trim()) 1051 1048 .filter(Boolean); 1049 + const cssOverrides = 1050 + typeof rawBody.cssOverrides === "string" ? rawBody.cssOverrides : undefined; 1052 1051 1053 1052 // Extract token values from form fields 1054 1053 const tokens: Record<string, string> = {}; ··· 1066 1065 apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1067 1066 method: "PUT", 1068 1067 headers: { "Content-Type": "application/json", Cookie: cookie }, 1069 - body: JSON.stringify({ name, colorScheme, tokens, fontUrls }), 1068 + body: JSON.stringify({ name, colorScheme, tokens, fontUrls, cssOverrides }), 1070 1069 }); 1071 1070 } catch (error) { 1072 1071 if (isProgrammingError(error)) throw error;
+1 -1
bruno/AppView API/Admin Themes/Create Theme.bru
··· 37 37 - name (required): Theme display name, non-empty 38 38 - colorScheme (required): "light" or "dark" 39 39 - tokens (required): Plain object of CSS design token key-value pairs. Values must be strings. 40 - - cssOverrides (optional): Raw CSS string for structural overrides (not rendered until ATB-62 sanitization ships) 40 + - cssOverrides (optional): Raw CSS string for structural overrides. Dangerous constructs (@import, external URLs, expression()) are stripped automatically on save. 41 41 - fontUrls (optional): Array of HTTPS URLs for font stylesheets 42 42 43 43 Returns (201):
+29
packages/css-sanitizer/package.json
··· 1 + { 2 + "name": "@atbb/css-sanitizer", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "main": "./dist/index.js", 7 + "types": "./dist/index.d.ts", 8 + "exports": { 9 + ".": { 10 + "types": "./dist/index.d.ts", 11 + "default": "./dist/index.js" 12 + } 13 + }, 14 + "scripts": { 15 + "build": "tsc", 16 + "test": "vitest run", 17 + "lint": "tsc --noEmit", 18 + "lint:fix": "oxlint --fix src/", 19 + "clean": "rm -rf dist" 20 + }, 21 + "dependencies": { 22 + "css-tree": "^2.3.1" 23 + }, 24 + "devDependencies": { 25 + "@types/node": "^22.0.0", 26 + "typescript": "^5.7.0", 27 + "vitest": "^3.1.0" 28 + } 29 + }
+297
packages/css-sanitizer/src/__tests__/index.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { sanitizeCssOverrides, sanitizeCss } from "../index.js"; 3 + 4 + // ─── sanitizeCssOverrides ──────────────────────────────────────────────────── 5 + 6 + describe("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 + // ── external url() in declarations ─────────────────────────────────────── 83 + 84 + it("strips declarations with http:// url()", () => { 85 + const { css, warnings } = sanitizeCssOverrides( 86 + 'body { background: url("http://evil.com/track.gif"); }' 87 + ); 88 + expect(css).not.toContain("evil.com"); 89 + expect(warnings.some((w) => w.includes("background"))).toBe(true); 90 + }); 91 + 92 + it("strips declarations with https:// url()", () => { 93 + const { css, warnings } = sanitizeCssOverrides( 94 + "body { background-image: url(https://evil.com/steal.gif); }" 95 + ); 96 + expect(css).not.toContain("evil.com"); 97 + expect(warnings.some((w) => w.includes("background-image"))).toBe(true); 98 + }); 99 + 100 + it("strips declarations with protocol-relative // url()", () => { 101 + const { css, warnings } = sanitizeCssOverrides( 102 + "body { background: url(//evil.com/steal.gif); }" 103 + ); 104 + expect(css).not.toContain("evil.com"); 105 + expect(warnings.length).toBeGreaterThan(0); 106 + }); 107 + 108 + it("strips content: url() on pseudo-elements", () => { 109 + const { css, warnings } = sanitizeCssOverrides( 110 + ".el::before { content: url('https://evil.com/pixel.gif'); }" 111 + ); 112 + expect(css).not.toContain("evil.com"); 113 + expect(warnings.some((w) => w.includes("content"))).toBe(true); 114 + }); 115 + 116 + // ── data: URIs ──────────────────────────────────────────────────────────── 117 + 118 + it("strips declarations with data: URI", () => { 119 + const { css, warnings } = sanitizeCssOverrides( 120 + 'body { background: url("data:text/html,<script>alert(1)</script>"); }' 121 + ); 122 + expect(css).not.toContain("data:"); 123 + expect(warnings.length).toBeGreaterThan(0); 124 + }); 125 + 126 + it("strips declarations with data: URI (unquoted)", () => { 127 + const { css, warnings } = sanitizeCssOverrides( 128 + "body { background: url(data:image/svg+xml;base64,PHN2Zy8+); }" 129 + ); 130 + expect(css).not.toContain("data:"); 131 + expect(warnings.length).toBeGreaterThan(0); 132 + }); 133 + 134 + // ── @font-face with external src ───────────────────────────────────────── 135 + 136 + it("strips @font-face with external https src", () => { 137 + const { css, warnings } = sanitizeCssOverrides( 138 + '@font-face { font-family: Evil; src: url("https://evil.com/font.woff2"); }' 139 + ); 140 + expect(css).not.toContain("@font-face"); 141 + expect(css).not.toContain("evil.com"); 142 + expect(warnings.some((w) => w.includes("@font-face"))).toBe(true); 143 + }); 144 + 145 + it("strips @font-face with external http src", () => { 146 + const { css, warnings } = sanitizeCssOverrides( 147 + '@font-face { font-family: Evil; src: url("http://evil.com/font.woff2"); }' 148 + ); 149 + expect(css).not.toContain("@font-face"); 150 + expect(warnings.some((w) => w.includes("@font-face"))).toBe(true); 151 + }); 152 + 153 + // ── expression() ───────────────────────────────────────────────────────── 154 + 155 + it("strips declarations with expression()", () => { 156 + const { css, warnings } = sanitizeCssOverrides( 157 + "body { color: expression(document.cookie); }" 158 + ); 159 + expect(css).not.toContain("expression("); 160 + expect(css).not.toContain("document.cookie"); 161 + expect(warnings.some((w) => w.includes("color"))).toBe(true); 162 + }); 163 + 164 + it("strips declarations with expression() in width", () => { 165 + const { css, warnings } = sanitizeCssOverrides( 166 + "div { width: expression(alert(1)); }" 167 + ); 168 + expect(css).not.toContain("expression("); 169 + expect(warnings.some((w) => w.includes("width"))).toBe(true); 170 + }); 171 + 172 + // ── dangerous properties ────────────────────────────────────────────────── 173 + 174 + it("strips -moz-binding property", () => { 175 + const { css, warnings } = sanitizeCssOverrides( 176 + 'body { -moz-binding: url("https://evil.com/xss.xml#xss"); }' 177 + ); 178 + expect(css).not.toContain("-moz-binding"); 179 + expect(css).not.toContain("evil.com"); 180 + expect(warnings.some((w) => w.includes("-moz-binding"))).toBe(true); 181 + }); 182 + 183 + it("strips behavior property", () => { 184 + const { css, warnings } = sanitizeCssOverrides( 185 + "body { behavior: url('evil.htc'); }" 186 + ); 187 + expect(css).not.toContain("behavior"); 188 + expect(warnings.some((w) => w.includes("behavior"))).toBe(true); 189 + }); 190 + 191 + it("strips -webkit-binding property", () => { 192 + const { css, warnings } = sanitizeCssOverrides( 193 + 'body { -webkit-binding: url("evil.xml"); }' 194 + ); 195 + expect(css).not.toContain("-webkit-binding"); 196 + expect(warnings.some((w) => w.includes("-webkit-binding"))).toBe(true); 197 + }); 198 + 199 + // ── javascript: URL ─────────────────────────────────────────────────────── 200 + 201 + it("strips declarations with javascript: URL", () => { 202 + const { css, warnings } = sanitizeCssOverrides( 203 + "body { background: url('javascript:alert(1)'); }" 204 + ); 205 + expect(css).not.toContain("javascript:"); 206 + expect(warnings.length).toBeGreaterThan(0); 207 + }); 208 + 209 + // ── mixed safe + unsafe ─────────────────────────────────────────────────── 210 + 211 + it("strips only the dangerous declarations, preserves safe ones", () => { 212 + const input = [ 213 + ".btn { color: red; }", 214 + 'body { background: url("https://evil.com/track.gif"); }', 215 + ".card { font-size: 14px; }", 216 + ].join("\n"); 217 + const { css, warnings } = sanitizeCssOverrides(input); 218 + expect(css).toContain("color:red"); 219 + expect(css).toContain("font-size:14px"); 220 + expect(css).not.toContain("evil.com"); 221 + expect(warnings).toHaveLength(1); 222 + }); 223 + 224 + it("strips unsafe inside @media but keeps the @media rule", () => { 225 + const input = ` 226 + @media (max-width: 768px) { 227 + body { background: url('https://evil.com/track.gif'); } 228 + .btn { color: red; } 229 + } 230 + `; 231 + const { css, warnings } = sanitizeCssOverrides(input); 232 + expect(css).toContain("@media"); 233 + expect(css).not.toContain("evil.com"); 234 + expect(css).toContain("color:red"); 235 + expect(warnings).toHaveLength(1); 236 + }); 237 + 238 + // ── warnings list ───────────────────────────────────────────────────────── 239 + 240 + it("returns a warning for each stripped construct", () => { 241 + const input = [ 242 + '@import "https://evil.com/a.css";', 243 + "body { background: url('https://evil.com/b.gif'); }", 244 + "body { color: expression(x); }", 245 + ].join("\n"); 246 + const { warnings } = sanitizeCssOverrides(input); 247 + expect(warnings).toHaveLength(3); 248 + }); 249 + 250 + // ── performance ─────────────────────────────────────────────────────────── 251 + 252 + it("sanitizes reasonable CSS in under 50ms", () => { 253 + // ~50 rules — a realistic CSS overrides block 254 + const rules = Array.from({ length: 50 }, (_, i) => 255 + `.class-${i} { color: var(--color-${i}); margin: ${i}px; }` 256 + ).join("\n"); 257 + 258 + const start = Date.now(); 259 + sanitizeCssOverrides(rules); 260 + expect(Date.now() - start).toBeLessThan(50); 261 + }); 262 + }); 263 + 264 + // ─── sanitizeCss (render-time wrapper) ─────────────────────────────────────── 265 + 266 + describe("sanitizeCss", () => { 267 + it("returns only the CSS string (no warnings object)", () => { 268 + const result = sanitizeCss(".btn { color: red; }"); 269 + expect(typeof result).toBe("string"); 270 + expect(result).toContain("color:red"); 271 + }); 272 + 273 + it("strips dangerous content and returns safe string", () => { 274 + const result = sanitizeCss('@import "https://evil.com/steal.css"; .ok { color: red; }'); 275 + expect(result).not.toContain("@import"); 276 + expect(result).toContain("color:red"); 277 + }); 278 + 279 + it("discards malformed CSS containing raw </style> (parse error → fail closed)", () => { 280 + // Raw </style> is invalid CSS syntax — css-tree's onParseError fires, 281 + // and the fail-closed policy discards the entire input for security. 282 + const result = sanitizeCss('body { color: red; } </style><script>alert(1)</script>'); 283 + expect(result).not.toContain("</style"); 284 + expect(result).not.toContain("<script>"); 285 + expect(result).toBe(""); 286 + }); 287 + 288 + it("strips </style> from CSS string literal values (HTML parser injection vector)", () => { 289 + // A CSS string literal containing </style> is valid CSS that passes parsing. 290 + // Without post-processing, generate() reproduces it verbatim and the HTML 291 + // parser would end the <style> block when injected via dangerouslySetInnerHTML. 292 + // Stripping </style is sufficient: <script> that follows it stays as harmless 293 + // CSS text inside the style block and cannot execute. 294 + const result = sanitizeCss('.foo::before { content: "</style><script>alert(1)</script>"; }'); 295 + expect(result).not.toContain("</style"); 296 + }); 297 + });
+47
packages/css-sanitizer/src/css-tree.d.ts
··· 1 + /** 2 + * Minimal type declaration for css-tree v2. 3 + * css-tree v2.x does not ship bundled TypeScript types. 4 + * These declarations cover only the subset of the API used by the sanitizer. 5 + */ 6 + declare module "css-tree" { 7 + interface CssNode { 8 + type: string; 9 + [key: string]: unknown; 10 + } 11 + 12 + interface List { 13 + remove(item: ListItem): void; 14 + } 15 + 16 + interface ListItem { 17 + data: CssNode; 18 + } 19 + 20 + function parse( 21 + css: string, 22 + options?: { 23 + parseValue?: boolean; 24 + onParseError?: (error: SyntaxError) => void; 25 + } 26 + ): CssNode; 27 + 28 + function walk( 29 + ast: CssNode, 30 + visitor: 31 + | ((node: CssNode, item: ListItem | null, list: List | null) => void) 32 + | { 33 + enter?: ( 34 + node: CssNode, 35 + item: ListItem | null, 36 + list: List | null 37 + ) => void; 38 + leave?: ( 39 + node: CssNode, 40 + item: ListItem | null, 41 + list: List | null 42 + ) => void; 43 + } 44 + ): void; 45 + 46 + function generate(ast: CssNode): string; 47 + }
+213
packages/css-sanitizer/src/index.ts
··· 1 + import * as csstree from "css-tree"; 2 + import type { CssNode, List, ListItem } from "css-tree"; 3 + 4 + export interface SanitizeResult { 5 + css: string; 6 + warnings: string[]; 7 + } 8 + 9 + // Properties that are dangerous regardless of value 10 + const DANGEROUS_PROPERTIES = new Set([ 11 + "-moz-binding", 12 + "behavior", 13 + "-webkit-binding", 14 + ]); 15 + 16 + function isExternalOrDataUrl(url: string): boolean { 17 + const lower = url.trim().toLowerCase(); 18 + return ( 19 + lower.startsWith("http://") || 20 + lower.startsWith("https://") || 21 + lower.startsWith("//") || 22 + lower.startsWith("ftp://") || 23 + lower.startsWith("javascript:") || 24 + lower.startsWith("data:") 25 + ); 26 + } 27 + 28 + /** 29 + * Returns true if the given AST subtree contains a url() with an external 30 + * or data: URI. In css-tree v2, Url.value is a decoded plain string. 31 + */ 32 + function subtreeContainsExternalUrl(node: CssNode): boolean { 33 + let found = false; 34 + csstree.walk(node, (inner: CssNode) => { 35 + if ( 36 + inner.type === "Url" && 37 + typeof inner.value === "string" && 38 + isExternalOrDataUrl(inner.value) 39 + ) { 40 + found = true; 41 + } 42 + }); 43 + return found; 44 + } 45 + 46 + /** 47 + * Returns true if the given AST subtree contains an expression() function. 48 + */ 49 + function subtreeContainsExpression(node: CssNode): boolean { 50 + let found = false; 51 + csstree.walk(node, (inner: CssNode) => { 52 + if ( 53 + inner.type === "Function" && 54 + typeof inner.name === "string" && 55 + inner.name.toLowerCase() === "expression" 56 + ) { 57 + found = true; 58 + } 59 + }); 60 + return found; 61 + } 62 + 63 + /** 64 + * Removes a node from its parent list and logs a warning on success. 65 + * If list/item is null the node cannot be removed; logs a distinct failure 66 + * message so the audit trail stays accurate. 67 + */ 68 + function removeNode( 69 + list: List | null, 70 + item: ListItem | null, 71 + successMessage: string, 72 + failureMessage: string, 73 + warnings: string[] 74 + ): void { 75 + if (list !== null && item !== null) { 76 + list.remove(item); 77 + warnings.push(successMessage); 78 + } else { 79 + warnings.push(failureMessage); 80 + } 81 + } 82 + 83 + /** 84 + * Sanitizes a CSS string intended for use as theme `cssOverrides`. 85 + * 86 + * Strips all constructs that can trigger network requests or execute code: 87 + * - @import rules 88 + * - @font-face rules with external src URLs 89 + * - Any declaration whose value contains an external url() or data: URI 90 + * - expression() function values (IE legacy execution vector) 91 + * - -moz-binding, behavior, -webkit-binding properties 92 + * 93 + * Returns the sanitized CSS string and a list of warning messages 94 + * describing what was stripped, suitable for structured logging. 95 + * 96 + * Returns empty CSS on any parse or generation error — fail closed. 97 + */ 98 + export function sanitizeCssOverrides(input: string): SanitizeResult { 99 + const warnings: string[] = []; 100 + 101 + if (!input.trim()) { 102 + return { css: "", warnings }; 103 + } 104 + 105 + let ast: CssNode; 106 + let parseErrorOccurred = false; 107 + try { 108 + ast = csstree.parse(input, { 109 + parseValue: true, 110 + onParseError: (_error: SyntaxError) => { 111 + // When css-tree recovers from a parse error it inserts Raw nodes whose 112 + // type is "Raw" — not "Declaration" or "Atrule" — so they bypass every 113 + // walker check below. Discard the entire output to stay fail-closed. 114 + parseErrorOccurred = true; 115 + }, 116 + }); 117 + } catch { 118 + warnings.push("CSS failed to parse — content discarded for security"); 119 + return { css: "", warnings }; 120 + } 121 + 122 + if (parseErrorOccurred) { 123 + warnings.push("CSS parse error encountered — content discarded for security"); 124 + return { css: "", warnings }; 125 + } 126 + 127 + csstree.walk(ast, { 128 + enter(node: CssNode, item: ListItem | null, list: List | null) { 129 + if (node.type === "Atrule") { 130 + const name = 131 + typeof node.name === "string" ? node.name.toLowerCase() : ""; 132 + 133 + if (name === "import") { 134 + removeNode( 135 + list, item, 136 + "Stripped @import rule", 137 + "Warning: @import rule detected but could not be removed", 138 + warnings 139 + ); 140 + return; 141 + } 142 + 143 + if (name === "font-face" && subtreeContainsExternalUrl(node)) { 144 + removeNode( 145 + list, item, 146 + "Stripped @font-face with external source URL", 147 + "Warning: @font-face with external URL detected but could not be removed", 148 + warnings 149 + ); 150 + return; 151 + } 152 + } 153 + 154 + if (node.type === "Declaration") { 155 + const property = 156 + typeof node.property === "string" 157 + ? node.property.toLowerCase() 158 + : ""; 159 + 160 + if (DANGEROUS_PROPERTIES.has(property)) { 161 + removeNode( 162 + list, item, 163 + `Stripped dangerous property: ${String(node.property)}`, 164 + `Warning: dangerous property ${String(node.property)} detected but could not be removed`, 165 + warnings 166 + ); 167 + return; 168 + } 169 + 170 + if (subtreeContainsExternalUrl(node)) { 171 + removeNode( 172 + list, item, 173 + `Stripped declaration with external URL: ${String(node.property)}`, 174 + `Warning: declaration with external URL in ${String(node.property)} detected but could not be removed`, 175 + warnings 176 + ); 177 + return; 178 + } 179 + 180 + if (subtreeContainsExpression(node)) { 181 + removeNode( 182 + list, item, 183 + `Stripped expression() in: ${String(node.property)}`, 184 + `Warning: expression() in ${String(node.property)} detected but could not be removed`, 185 + warnings 186 + ); 187 + return; 188 + } 189 + } 190 + }, 191 + }); 192 + 193 + let generated: string; 194 + try { 195 + generated = csstree.generate(ast); 196 + } catch { 197 + warnings.push("CSS generation failed after sanitization — content discarded for security"); 198 + return { css: "", warnings }; 199 + } 200 + 201 + // Strip </style> sequences that could break out of the <style> block when 202 + // CSS is injected via dangerouslySetInnerHTML (which bypasses JSX escaping). 203 + const htmlSafe = generated.replace(/<\/style/gi, ""); 204 + return { css: htmlSafe, warnings }; 205 + } 206 + 207 + /** 208 + * Render-time sanitizer: thin wrapper that returns just the sanitized CSS string. 209 + * Use this in template/layout code where you only need the clean string. 210 + */ 211 + export function sanitizeCss(input: string): string { 212 + return sanitizeCssOverrides(input).css; 213 + }
+8
packages/css-sanitizer/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.base.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*.ts"] 8 + }
+7
packages/css-sanitizer/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + environment: "node", 6 + }, 7 + });
+36 -1
pnpm-lock.yaml
··· 29 29 '@atbb/atproto': 30 30 specifier: workspace:* 31 31 version: link:../../packages/atproto 32 + '@atbb/css-sanitizer': 33 + specifier: workspace:* 34 + version: link:../../packages/css-sanitizer 32 35 '@atbb/db': 33 36 specifier: workspace:* 34 37 version: link:../../packages/db ··· 90 93 91 94 apps/web: 92 95 dependencies: 96 + '@atbb/css-sanitizer': 97 + specifier: workspace:* 98 + version: link:../../packages/css-sanitizer 93 99 '@atbb/logger': 94 100 specifier: workspace:* 95 101 version: link:../../packages/logger ··· 181 187 specifier: ^3.0.0 182 188 version: 3.2.4(@types/node@22.19.9)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2) 183 189 190 + packages/css-sanitizer: 191 + dependencies: 192 + css-tree: 193 + specifier: ^2.3.1 194 + version: 2.3.1 195 + devDependencies: 196 + '@types/node': 197 + specifier: ^22.0.0 198 + version: 22.19.9 199 + typescript: 200 + specifier: ^5.7.0 201 + version: 5.9.3 202 + vitest: 203 + specifier: ^3.1.0 204 + version: 3.2.4(@types/node@22.19.9)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2) 205 + 184 206 packages/db: 185 207 dependencies: 186 208 '@libsql/client': ··· 1489 1511 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 1490 1512 engines: {node: '>= 8'} 1491 1513 1514 + css-tree@2.3.1: 1515 + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} 1516 + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} 1517 + 1492 1518 css-tree@3.1.0: 1493 1519 resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} 1494 1520 engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} ··· 1837 1863 1838 1864 libsql@0.4.7: 1839 1865 resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} 1840 - cpu: [x64, arm64, wasm32] 1841 1866 os: [darwin, linux, win32] 1842 1867 1843 1868 loupe@3.2.1: ··· 1857 1882 magic-string@0.30.21: 1858 1883 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 1859 1884 1885 + mdn-data@2.0.30: 1886 + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} 1887 + 1860 1888 mdn-data@2.12.2: 1861 1889 resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} 1862 1890 ··· 3404 3432 shebang-command: 2.0.0 3405 3433 which: 2.0.2 3406 3434 3435 + css-tree@2.3.1: 3436 + dependencies: 3437 + mdn-data: 2.0.30 3438 + source-map-js: 1.2.1 3439 + 3407 3440 css-tree@3.1.0: 3408 3441 dependencies: 3409 3442 mdn-data: 2.12.2 ··· 3742 3775 magic-string@0.30.21: 3743 3776 dependencies: 3744 3777 '@jridgewell/sourcemap-codec': 1.5.5 3778 + 3779 + mdn-data@2.0.30: {} 3745 3780 3746 3781 mdn-data@2.12.2: {} 3747 3782