aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Utility
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/Utility')
-rw-r--r--src/lib/Utility/sanitizeCss.test.ts76
-rw-r--r--src/lib/Utility/sanitizeCss.ts57
2 files changed, 133 insertions, 0 deletions
diff --git a/src/lib/Utility/sanitizeCss.test.ts b/src/lib/Utility/sanitizeCss.test.ts
new file mode 100644
index 00000000..f6c22364
--- /dev/null
+++ b/src/lib/Utility/sanitizeCss.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import { sanitizeBadgeWallCss } from "./sanitizeCss";
+
+describe("sanitizeBadgeWallCss", () => {
+ // Behaviour gate: the CSS people actually write for their badge wall survives.
+ it("preserves ordinary rules, declarations and values", () => {
+ const out = sanitizeBadgeWallCss(
+ ".badge { color: red; opacity: 0.5; border-radius: 8px; }",
+ );
+
+ expect(out).toContain("color");
+ expect(out).toContain("red");
+ expect(out).toContain("border-radius");
+ });
+
+ it("preserves backdrop-filter, content, url() and at-rules", () => {
+ expect(
+ sanitizeBadgeWallCss(".x { backdrop-filter: blur(4px); }"),
+ ).toContain("backdrop-filter");
+ expect(sanitizeBadgeWallCss('.x::before { content: "★"; }')).toContain(
+ "content",
+ );
+ expect(
+ sanitizeBadgeWallCss(
+ ".x { background: url(https://cdn.due.moe/a.png); }",
+ ),
+ ).toContain("url(https://cdn.due.moe/a.png)");
+ expect(
+ sanitizeBadgeWallCss("@media (min-width: 1px) { .x { color: blue; } }"),
+ ).toContain("@media");
+ expect(
+ sanitizeBadgeWallCss(
+ "@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }",
+ ),
+ ).toContain("@keyframes");
+ });
+
+ it("returns empty string for nullish input", () => {
+ expect(sanitizeBadgeWallCss("")).toBe("");
+ // @ts-expect-error exercising defensive nullish handling
+ expect(sanitizeBadgeWallCss(undefined)).toBe("");
+ });
+
+ // The fix: dangerous constructs are removed while surrounding CSS is kept.
+ it("strips @import, behavior, -moz-binding, expression and js: urls", () => {
+ const imported = sanitizeBadgeWallCss(
+ "@import url(https://evil.example.com/x.css); .x { color: red; }",
+ );
+ expect(imported).not.toContain("@import");
+ expect(imported).toContain("color");
+
+ expect(
+ sanitizeBadgeWallCss(".x { behavior: url(evil.htc); color: red; }"),
+ ).not.toContain("behavior");
+ expect(
+ sanitizeBadgeWallCss(".x { -moz-binding: url(evil.xml#x); }"),
+ ).not.toContain("-moz-binding");
+ expect(
+ sanitizeBadgeWallCss(".x { width: expression(alert(1)); }"),
+ ).not.toContain("expression");
+ expect(
+ sanitizeBadgeWallCss(".x { background: url(javascript:alert(1)); }"),
+ ).not.toContain("javascript:");
+ });
+
+ it("drops <style> break-out attempts entirely", () => {
+ const out = sanitizeBadgeWallCss(
+ ".a { color: red; } </style><script>window.x=1</script> .b { color: blue; }",
+ );
+
+ expect(out).not.toContain("<script");
+ expect(out).not.toContain("</style");
+ expect(out).not.toContain("window.x");
+ expect(out).toContain("color");
+ });
+});
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 `</style>` 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
+ // `</style><script>…` break-out shows up as a rule whose selector
+ // contains `<` (never valid in a real selector). Drop the rule.
+ list.remove(item);
+ } else if (node.type === "Raw" && /[<>]/.test(node.value)) {
+ list.remove(item);
+ } else if (
+ node.type === "Declaration" &&
+ (blockedProperties.has(node.property.toLowerCase()) ||
+ dangerousValue.test(csstree.generate(node.value)))
+ ) {
+ list.remove(item);
+ }
+ });
+
+ try {
+ return csstree.generate(ast);
+ } catch {
+ return "";
+ }
+};