From 31b825b183bfae702c8ceb2fa48b29a4b830cf73 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 2 Jun 2026 00:25:29 +0000 Subject: fix(security): sanitize badge_wall_css server-side, render via textContent Custom badge-wall CSS was sanitised only client-side with a fragile regex and injected via innerHTML, while the stored value stayed raw. Sanitise at the write boundary instead (setCSS, covering both the REST and GraphQL paths) with a css-tree pass that parses leniently and drops @import, behavior/-moz-binding, expression()/javascript: values, and break-out attempts; render with textContent instead of innerHTML so break-out is impossible by construction (CSP already blocks inline script). css-tree stays server-only. A behaviour-gate test confirms ordinary CSS (backdrop-filter, content, url(), @media, @keyframes) is preserved while the dangerous constructs are removed. The previous regex also silently stripped all `content:` declarations; those now render correctly. --- src/lib/Utility/sanitizeCss.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/lib/Utility/sanitizeCss.ts (limited to 'src/lib/Utility/sanitizeCss.ts') diff --git a/src/lib/Utility/sanitizeCss.ts b/src/lib/Utility/sanitizeCss.ts new file mode 100644 index 00000000..976fa6a1 --- /dev/null +++ b/src/lib/Utility/sanitizeCss.ts @@ -0,0 +1,57 @@ +import * as csstree from "css-tree"; + +const blockedProperties = new Set(["behavior", "-moz-binding"]); +const dangerousValue = /expression\s*\(|javascript:|vbscript:/i; + +/** + * Sanitise user-supplied badge-wall CSS at the trust boundary (write time), so + * the stored value is safe regardless of how it is later rendered. Parsing with + * css-tree (leniently, like a browser) drops anything that isn't valid CSS — + * including `` break-out attempts — and we additionally remove the few + * constructs that can load resources or (in legacy engines) run code: + * `@import`, `behavior`/`-moz-binding`, and `expression()`/`javascript:` values. + * + * This is defence in depth: rendering goes through `textContent` (no HTML + * parsing, so no break-out) and the CSP blocks inline script regardless. + */ +export const sanitizeBadgeWallCss = (css: string): string => { + if (!css) return ""; + + let ast: csstree.CssNode; + + try { + ast = csstree.parse(css, { onParseError: () => {} }); + } catch { + return ""; + } + + csstree.walk(ast, (node, item, list) => { + if (!list || !item) return; + + if (node.type === "Atrule" && node.name.toLowerCase() === "import") { + list.remove(item); + } else if ( + node.type === "Rule" && + csstree.generate(node.prelude).includes("<") + ) { + // css-tree keeps an unparseable selector as a Raw prelude, so a + // `