diff options
| -rw-r--r-- | docs/vercel-firewall.md | 43 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 25 | ||||
| -rw-r--r-- | src/lib/Database/SB/User/preferences.ts | 3 | ||||
| -rw-r--r-- | src/lib/Error/rateLimit.ts | 64 | ||||
| -rw-r--r-- | src/lib/Utility/sanitizeCss.test.ts | 76 | ||||
| -rw-r--r-- | src/lib/Utility/sanitizeCss.ts | 57 | ||||
| -rw-r--r-- | src/routes/api/badges/+server.ts | 9 | ||||
| -rw-r--r-- | src/routes/user/[user]/badges/+page.svelte | 13 |
9 files changed, 271 insertions, 21 deletions
diff --git a/docs/vercel-firewall.md b/docs/vercel-firewall.md new file mode 100644 index 00000000..0d3fdbff --- /dev/null +++ b/docs/vercel-firewall.md @@ -0,0 +1,43 @@ +# Vercel WAF — rate-limiting rules + +Coarse, per-IP abuse protection that runs at the edge, complementing the +app-level limiters in `src/lib/Error/rateLimit.ts` (which handle what the WAF +cannot — per-user windows and the per-(IP, badge) click counter). + +These rules are **not committable config** — Vercel WAF rules are managed in the +dashboard (or via Terraform / the REST API), not `vercel.json`. This file is the +spec to enter under **Project → Firewall → Configure → + New Rule**. + +Caveats from the platform: + +- Counters are **per region** — traffic spread across regions can exceed a + single-region limit. +- Counting keys on Hobby/Pro are **IP / JA4 only** (no app data like a badge id; + that is why the click counter is limited app-side instead). +- Window: min **10s**, max **10min** (Hobby/Pro). +- **Hobby allows only 1 rule per project**; Pro allows 40. +- Start each rule's action as **Log** to observe real traffic, then switch to + **Deny (429)** once the thresholds look right. + +## Hobby (single rule) + +| Field | Value | +| --- | --- | +| Name | `api-mutations-ratelimit` | +| If | `Request Path` starts with `/api/` **AND** `Request Method` is one of `POST`, `PUT`, `DELETE` | +| Then | Rate Limit — Fixed Window | +| Window / Limit | `60s` / `100` requests | +| Key | `IP` | +| Action | `Deny` (429) | + +## Pro (targeted rules) + +| Name | If | Window / Limit | Key | Action | +| --- | --- | --- | --- | --- | +| `auth-ratelimit` | Path starts with `/api/oauth/` | `60s` / `20` | IP | Deny | +| `mutations-ratelimit` | Path is one of `/api/badges`, `/api/preferences`, `/api/configuration` **or** starts with `/api/notifications/`; Method `POST`/`PUT`/`DELETE` | `60s` / `60` | IP | Deny | +| `graphql-ratelimit` | Path is `/graphql`; Method `POST` | `60s` / `60` | IP | Deny | + +Public GET reads and the click counter are left to the CDN cache and the +app-level limiters respectively; add a coarse `/api/*` GET rule only if you see +read abuse. diff --git a/package.json b/package.json index ac44767c..c6f795a5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@sveltejs/adapter-vercel": "5.0.0", "@sveltejs/kit": "2.5.27", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/css-tree": "^2.3.11", "@types/fast-levenshtein": "^0.0.4", "@types/jsdom": "^21.1.6", "@types/string-similarity": "^4.0.2", @@ -57,6 +58,7 @@ "@vercel/speed-insights": "^1.0.9", "botid": "^1.5.10", "caniuse-lite": "^1.0.30001655", + "css-tree": "^3.2.1", "dexie": "^4.0.1-alpha.25", "dompurify": "^3.4.7", "effect": "4.0.0-beta.25", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc1644e5..d270e294 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: caniuse-lite: specifier: ^1.0.30001655 version: 1.0.30001766 + css-tree: + specifier: ^3.2.1 + version: 3.2.1 dexie: specifier: ^4.0.1-alpha.25 version: 4.2.1 @@ -96,6 +99,9 @@ importers: '@sveltejs/vite-plugin-svelte': specifier: ^4.0.0 + '@types/css-tree': + specifier: ^2.3.11 + version: 2.3.11 '@types/fast-levenshtein': specifier: ^0.0.4 version: 0.0.4 @@ -2072,6 +2078,9 @@ packages: '@types/[email protected]': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/[email protected]': + resolution: {integrity: sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==} + '@types/[email protected]': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2569,6 +2578,10 @@ packages: resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -3404,6 +3417,9 @@ packages: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -6360,6 +6376,8 @@ snapshots: dependencies: '@types/node': 17.0.45 + '@types/[email protected]': {} + '@types/[email protected]': {} '@types/[email protected]': {} @@ -6839,6 +6857,11 @@ snapshots: mdn-data: 2.0.30 source-map-js: 1.2.1 + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -7948,6 +7971,8 @@ snapshots: + [email protected]: {} + dependencies: fs-monkey: 1.1.0 diff --git a/src/lib/Database/SB/User/preferences.ts b/src/lib/Database/SB/User/preferences.ts index d1f03ee8..755f21b2 100644 --- a/src/lib/Database/SB/User/preferences.ts +++ b/src/lib/Database/SB/User/preferences.ts @@ -1,3 +1,4 @@ +import { sanitizeBadgeWallCss } from "$lib/Utility/sanitizeCss"; import sb from "../../sb.server"; export interface UserPreferences { @@ -122,7 +123,7 @@ export const toggleHideAWCBadges = async (userId: number) => { export const setCSS = async (userId: number, css: string) => { return await setUserPreferences(userId, { updated_at: new Date().toISOString(), - badge_wall_css: css, + badge_wall_css: sanitizeBadgeWallCss(css), }); }; diff --git a/src/lib/Error/rateLimit.ts b/src/lib/Error/rateLimit.ts index 636bdd5d..d3508aba 100644 --- a/src/lib/Error/rateLimit.ts +++ b/src/lib/Error/rateLimit.ts @@ -1,12 +1,62 @@ import type { RequestEvent } from "@sveltejs/kit"; -import { RateLimiter } from "sveltekit-rate-limiter/server"; +import { + RateLimiter, + type RateLimiterStore, +} from "sveltekit-rate-limiter/server"; -export const checkRateLimit = async (event: RequestEvent) => { - const limiter = new RateLimiter({ rates: { IP: [5, "s"] } }); +// Storage. The library's default store is in-memory and PER INSTANCE. On +// Vercel's serverless runtime, counters do not aggregate across lambda +// instances or regions, so these limiters are best-effort backstops there. +// Provision a shared store (Upstash Redis / Vercel KV) implementing +// RateLimiterStore and return it here for durable limits. Coarse per-IP abuse +// protection also runs at the edge via the Vercel WAF (see +// docs/vercel-firewall.md); these app-level limiters add what the WAF cannot +// express — per-user windows and the per-(IP, badge) click counter. +const createStore = (): RateLimiterStore | undefined => { + // e.g. `if (env.UPSTASH_REDIS_REST_URL) return new UpstashStore(...)` + return undefined; +}; - await limiter.cookieLimiter?.preflight(event); +const store = createStore(); - if (await limiter.isLimited(event)) return new Response("rate-limited"); +// Module-level singletons: the counters must persist across requests. (The +// previous implementation built a new RateLimiter on every call, so its store +// was always empty and it never actually limited anything.) +const limiters = { + auth: new RateLimiter({ IP: [10, "m"], store }), + mutation: new RateLimiter({ IP: [30, "m"], store }), + read: new RateLimiter({ IP: [60, "m"], store }), +} satisfies Record<string, RateLimiter>; - return null; -}; +export type RateLimitClass = keyof typeof limiters; + +const tooManyRequests = () => + new Response("Too Many Requests", { status: 429 }); + +export const checkRateLimit = async ( + event: RequestEvent, + limit: RateLimitClass = "mutation", +): Promise<Response | null> => + (await limiters[limit].isLimited(event)) ? tooManyRequests() : null; + +// Click counter: bound inflation of a single badge without throttling a viewer +// browsing many DIFFERENT badges. Keyed on (IP, badge) — which the edge WAF +// can't express, since it keys on IP alone. +const clickCounterLimiter = new RateLimiter({ + store, + plugins: [ + { + rate: [2, "m"], + hash: async (event) => { + const badgeId = event.url.searchParams.get("incrementClickCount"); + + return badgeId ? `click:${event.getClientAddress()}:${badgeId}` : true; + }, + }, + ], +}); + +export const checkClickCounterLimit = async ( + event: RequestEvent, +): Promise<Response | null> => + (await clickCounterLimiter.isLimited(event)) ? tooManyRequests() : null; 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 ""; + } +}; diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts index 10b63125..a4212f40 100644 --- a/src/routes/api/badges/+server.ts +++ b/src/routes/api/badges/+server.ts @@ -15,6 +15,7 @@ import { } from "$lib/Database/SB/User/badges"; import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie"; import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody"; +import { checkClickCounterLimit } from "$lib/Error/rateLimit"; import { appOrigin, appOriginHeaders } from "$lib/Utility/appOrigin"; import { isOwnerOrPrivileged } from "$lib/Utility/authorisation"; import privilegedUser from "$lib/Utility/privilegedUser"; @@ -53,8 +54,14 @@ export const DELETE = async ({ url, cookies }) => { return await badges(identity.id); }; -export const PUT = async ({ cookies, url, request }) => { +export const PUT = async (event) => { + const { cookies, url, request } = event; + if (url.searchParams.get("incrementClickCount") || undefined) { + const limited = await checkClickCounterLimit(event); + + if (limited) return limited; + if (request.headers.get("origin") !== appOrigin()) return unauthorised(); await incrementClickCount( diff --git a/src/routes/user/[user]/badges/+page.svelte b/src/routes/user/[user]/badges/+page.svelte index dc43fc08..605f5675 100644 --- a/src/routes/user/[user]/badges/+page.svelte +++ b/src/routes/user/[user]/badges/+page.svelte @@ -40,21 +40,10 @@ $: preferences = $BadgeWallUser.fetching : ($BadgeWallUser.data?.User?.preferences as Preferences | undefined); $: if (browser && preferences && preferences.badge_wall_css) { - const sanitise = (css: string) => - css - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/<\/?[^>]+(>|$)/g, "") - .replace( - /(expression|javascript|vbscript|onerror|onload|onclick|onmouseover|onmouseout|onmouseup|onmousedown|onkeydown|onkeyup|onkeypress|onblur|onfocus|onsubmit|onreset|onselect|onchange|ondblclick):/gi, - "", - ) - .replace(/(behaviour|behavior|moz-binding|content):/gi, "") - .replace(/\s+/g, " ") - .trim(); const style = document.createElement("style"); style.dataset.badgeWall = "true"; - style.innerHTML = sanitise(preferences.badge_wall_css); + style.textContent = preferences.badge_wall_css; document.head.appendChild(style); } |