diff --git a/.env.example b/.env.example index 28aad20..77301cc 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ JWT_SECRET=change-me-in-production # For production: https://yourdomain.com ORIGIN=http://localhost:8000 +# Extra origins allowed to submit forms (comma-separated, full origin incl. scheme+port). +# Supports glob, e.g. http://*.ts.net:8003, or "*" to allow all (trusted networks only). +# Optional; when unset, only ORIGIN is allowed. +# TRUSTED_ORIGINS= + # ─── Database ───────────────────────────────────────────── # Docker (default): /app/data/invio.db # Local development: ./invio.db diff --git a/frontend/bun.lock b/frontend/bun.lock index 444bfbf..bf16629 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -16,6 +16,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", + "@types/bun": "^1.4.0", "@types/node": "^25.5.1", "@typescript-eslint/parser": "^8.59.0", "eslint": "^10.2.0", @@ -223,6 +224,8 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], @@ -269,6 +272,8 @@ "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], diff --git a/frontend/package.json b/frontend/package.json index befcbb7..9d96e07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "bun test" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", @@ -21,6 +22,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", + "@types/bun": "^1.4.0", "@types/node": "^25.5.1", "@typescript-eslint/parser": "^8.59.0", "eslint": "^10.2.0", diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index a826500..22542b0 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,9 +1,42 @@ -import type { Handle } from "@sveltejs/kit"; +import { dev } from "$app/environment"; +import { error, type Handle } from "@sveltejs/kit"; +import { env } from "$env/dynamic/private"; import { resolveLocalization, DEFAULT_LOCALIZATION } from "$lib/i18n/mod"; import { getAuthHeaderFromCookie } from "$lib/auth"; import { backendGet } from "$lib/backend"; +import { + isMutatingFormRequest, + originAllowed, + originMatchesRequestHost, + parseAllowedOrigins, +} from "$lib/csrf"; + +function assertTrustedFormOrigin(event: Parameters[0]["event"]): void { + if (!isMutatingFormRequest(event.request)) { + return; + } + + const origin = event.request.headers.get("origin") ?? ""; + const allowedOrigins = parseAllowedOrigins(env.ORIGIN, env.TRUSTED_ORIGINS); + + const allowed = + allowedOrigins.length > 0 + ? originAllowed(origin, allowedOrigins) + : originMatchesRequestHost(origin, event.request.headers.get("host")); + + if (!allowed) { + error( + 403, + `Cross-site ${event.request.method} form submissions are forbidden`, + ); + } +} export const handle: Handle = async ({ event, resolve }) => { + if (!dev) { + assertTrustedFormOrigin(event); + } + const cookieString = event.request.headers.get("cookie"); // Auth diff --git a/frontend/src/lib/csrf.test.ts b/frontend/src/lib/csrf.test.ts new file mode 100644 index 0000000..76f3a09 --- /dev/null +++ b/frontend/src/lib/csrf.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { + originAllowed, + originMatchesRequestHost, + parseAllowedOrigins, + isMutatingFormRequest, +} from "./csrf"; + +describe("originAllowed", () => { + test("exact match", () => { + expect( + originAllowed("http://localhost:8000", ["http://localhost:8000"]), + ).toBe(true); + }); + + test("hostname mismatch is denied", () => { + expect( + originAllowed("http://127.0.0.1:8000", ["http://localhost:8000"]), + ).toBe(false); + }); + + test("missing origin is denied", () => { + expect(originAllowed("", ["http://localhost:8000"])).toBe(false); + expect(originAllowed("", ["*"])).toBe(false); + }); + + test("wildcard allows any origin", () => { + expect(originAllowed("http://evil.example:8000", ["*"])).toBe(true); + }); + + test("glob matches Tailscale MagicDNS", () => { + expect( + originAllowed("http://umbrel-home.tailf2e95.ts.net:8003", [ + "http://*.ts.net:8003", + ]), + ).toBe(true); + }); + + test("glob rejects other hosts and ports", () => { + expect( + originAllowed("http://evil.example:8003", ["http://*.ts.net:8003"]), + ).toBe(false); + expect( + originAllowed("http://umbrel-home.tailf2e95.ts.net:8000", [ + "http://*.ts.net:8003", + ]), + ).toBe(false); + }); +}); + +describe("parseAllowedOrigins", () => { + test("ORIGIN only", () => { + expect(parseAllowedOrigins("http://umbrel.local:8003", undefined)).toEqual([ + "http://umbrel.local:8003", + ]); + }); + + test("ORIGIN plus TRUSTED_ORIGINS", () => { + expect( + parseAllowedOrigins( + "http://app.local:8000", + "http://tailscale.local:8000, http://*.ts.net:8003", + ), + ).toEqual([ + "http://app.local:8000", + "http://tailscale.local:8000", + "http://*.ts.net:8003", + ]); + }); + + test("both env vars empty yields empty list", () => { + expect(parseAllowedOrigins(undefined, undefined)).toEqual([]); + expect(parseAllowedOrigins("", "")).toEqual([]); + }); +}); + +describe("originMatchesRequestHost", () => { + test("matches Host including port, ignoring http vs https", () => { + expect( + originMatchesRequestHost("http://app.local:18000", "app.local:18000"), + ).toBe(true); + expect( + originMatchesRequestHost("https://app.local:18000", "app.local:18000"), + ).toBe(true); + }); + + test("rejects a different hostname", () => { + expect( + originMatchesRequestHost( + "http://tailscale.local:18000", + "app.local:18000", + ), + ).toBe(false); + }); +}); + +describe("isMutatingFormRequest", () => { + test("login form POST is checked", () => { + const request = new Request("http://localhost/login?/login", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + expect(isMutatingFormRequest(request)).toBe(true); + }); + + test("GET is not checked", () => { + const request = new Request("http://localhost/login"); + expect(isMutatingFormRequest(request)).toBe(false); + }); +}); diff --git a/frontend/src/lib/csrf.ts b/frontend/src/lib/csrf.ts new file mode 100644 index 0000000..312e18e --- /dev/null +++ b/frontend/src/lib/csrf.ts @@ -0,0 +1,67 @@ +const FORM_CONTENT_TYPES = new Set([ + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", +]); + +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export function isFormContentType(request: Request): boolean { + const type = + request.headers + .get("content-type") + ?.split(";", 1)[0] + .trim() + .toLowerCase() ?? ""; + return FORM_CONTENT_TYPES.has(type); +} + +export function isMutatingFormRequest(request: Request): boolean { + return MUTATING_METHODS.has(request.method) && isFormContentType(request); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function originAllowed(origin: string, patterns: string[]): boolean { + if (!origin) return false; + for (const pattern of patterns) { + if (pattern === "*") return true; + if (pattern === origin) return true; + if (pattern.includes("*")) { + const regex = new RegExp( + "^" + pattern.split("*").map(escapeRegex).join(".+") + "$", + ); + if (regex.test(origin)) return true; + } + } + return false; +} + +export function parseAllowedOrigins( + originEnv: string | undefined, + trustedOriginsEnv: string | undefined, +): string[] { + return [ + ...(originEnv?.trim() ? [originEnv.trim()] : []), + ...(trustedOriginsEnv ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ]; +} + +/** True when Origin's host[:port] matches the incoming Host header (scheme ignored). */ +export function originMatchesRequestHost( + origin: string, + hostHeader: string | null | undefined, +): boolean { + if (!origin || !hostHeader) return false; + try { + const originUrl = new URL(origin); + return originUrl.host === hostHeader || originUrl.hostname === hostHeader; + } catch { + return false; + } +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index 6d78af5..51ac06b 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -6,6 +6,9 @@ const config = { adapter: adapter({ out: "build", }), + csrf: { + trustedOrigins: ["*"], + }, }, vitePlugin: { dynamicCompileOptions: ({ filename }) =>