diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4b380a6..e24cfbd 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,8 +1,11 @@ import type { Handle } from "@sveltejs/kit"; import { error, redirect } from "@sveltejs/kit"; +import { sequence } from "@sveltejs/kit/hooks"; import { env } from "$env/dynamic/private" +import { csrf } from "$lib/server/csrf" + import SessionStore from "$lib/server/session_store" import { init_db, close_db, create_user, do_users_exist } from "$lib/server/database"; @@ -63,7 +66,7 @@ process.on("uncaughtExceptionMonitor", (error, origin) => { await init(); -export let handle: Handle = async function ({ event, resolve }) { +export let handle: Handle = sequence(csrf([], []), async function ({ event, resolve }) { event.locals.setup = local_setup Logs.route.debug(`incoming ${event.request.method} request to: ${event.url.href} (route id: ${event.route.id})`); @@ -106,4 +109,4 @@ export let handle: Handle = async function ({ event, resolve }) { event.locals.user = user; return await resolve(event); -} +}) diff --git a/src/lib/server/csrf.ts b/src/lib/server/csrf.ts new file mode 100644 index 0000000..b52f21e --- /dev/null +++ b/src/lib/server/csrf.ts @@ -0,0 +1,60 @@ +/* + * Imported and adapted from https://gist.github.com/Maxiviper117/95a31750b74510bbb413d2e4ae20b4e8 on 27.07.2025 + * */ + +import type { Handle } from '@sveltejs/kit'; +import { json, text } from '@sveltejs/kit'; + +/** + * CSRF protection middleware for SvelteKit. + * + * @param allowedPaths - Paths that bypass CSRF protection. + * @param allowedOrigins - Trusted origins that can submit cross-origin forms. + * @returns SvelteKit handle function. + */ +export function csrf(allowedPaths: string[], allowedOrigins: string[] = []): Handle { + return async ({ event, resolve }) => { + const { request, url } = event; + const requestOrigin = request.headers.get('origin'); + const isSameOrigin = requestOrigin === url.origin; + const isAllowedOrigin = allowedOrigins.includes(requestOrigin ?? ''); + + // Block form submissions that don't match CSRF rules + const forbidden = + isFormContentType(request) && + ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && + !isSameOrigin && // Not from same origin + !isAllowedOrigin && // Not in allowed origins + !allowedPaths.includes(url.pathname); // Path is not explicitly allowed + + if (forbidden) { + const message = `Cross-site ${request.method} form submissions are forbidden`; + if (request.headers.get('accept') === 'application/json') { + return json({ message }, { status: 403 }); + } + return text(message, { status: 403 }); + } + + return resolve(event); + }; +} + +/** + * Check if request content type matches given types. + */ +function isContentType(request: Request, ...types: string[]) { + const type = request.headers.get('content-type')?.split(';', 1)[0].trim() ?? ''; + return types.includes(type.toLowerCase()); +} + +/** + * Determines if a request is a form submission. + */ +function isFormContentType(request: Request) { + return isContentType( + request, + 'application/x-www-form-urlencoded', + 'multipart/form-data', + 'text/plain' + ); +} diff --git a/svelte.config.js b/svelte.config.js index 3931d77..a4a5a9d 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -28,7 +28,7 @@ const config = { } }, - checkOrigin: true, + checkOrigin: false, //warningFilter: (warning) => !warning.code.startsWith('a11y') };