aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/vercel-firewall.md43
-rw-r--r--package.json2
-rw-r--r--pnpm-lock.yaml25
-rw-r--r--src/lib/Database/SB/User/preferences.ts3
-rw-r--r--src/lib/Error/rateLimit.ts64
-rw-r--r--src/lib/Utility/sanitizeCss.test.ts76
-rw-r--r--src/lib/Utility/sanitizeCss.ts57
-rw-r--r--src/routes/api/badges/+server.ts9
-rw-r--r--src/routes/user/[user]/badges/+page.svelte13
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:
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+ '@types/[email protected]':
+ resolution: {integrity: sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==}
+
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:
+
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);
}