diff --git a/.changeset/plain-games-roll.md b/.changeset/plain-games-roll.md new file mode 100644 index 000000000000..287f950f3868 --- /dev/null +++ b/.changeset/plain-games-roll.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +feat: add CSRF allowedOrigins bypass list diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 419f30416d9c..d77890510285 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -68,7 +68,8 @@ const get_defaults = (prefix = '') => ({ reportOnly: directive_defaults }, csrf: { - checkOrigin: true + checkOrigin: true, + allowedOrigins: [] }, embedded: false, env: { diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index a2b9bb81759d..797ef00d5ddc 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -109,7 +109,8 @@ const options = object( }), csrf: object({ - checkOrigin: boolean(true) + checkOrigin: boolean(true), + allowedOrigins: string_array([]) }), embedded: boolean(false), diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 5e93d5c1cd25..f1b51d426efe 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -38,6 +38,7 @@ export const options = { app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')}, csp: ${s(config.kit.csp)}, csrf_check_origin: ${s(config.kit.csrf.checkOrigin)}, + csrf_allowed_origins: ${s(config.kit.csrf.allowedOrigins)}, embedded: ${config.kit.embedded}, env_public_prefix: '${config.kit.env.publicPrefix}', env_private_prefix: '${config.kit.env.privatePrefix}', diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index afc5f1d6d450..7359ac61425d 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -373,6 +373,17 @@ export interface KitConfig { * @default true */ checkOrigin?: boolean; + /** + * An array of origins that are allowed to make cross-origin form submissions to your app, even when `checkOrigin` is `true`. + * + * Each origin should be a complete origin including protocol (e.g., `https://payment-gateway.com`). + * This is useful for allowing trusted third-party services like payment gateways or authentication providers to submit forms to your app. + * + * **Warning**: Only add origins you completely trust, as this bypasses CSRF protection for those origins. + * @default [] + * @example ['https://checkout.stripe.com', 'https://accounts.google.com'] + */ + allowedOrigins?: string[]; }; /** * Whether or not the app is embedded inside a larger app. If `true`, SvelteKit will add its event listeners related to navigation etc on the parent of `%sveltekit.body%` instead of `window`, and will pass `params` from the server rather than inferring them from `location.pathname`. diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index d37659cf899b..92929a0bc2fc 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -65,13 +65,15 @@ export async function respond(request, options, manifest, state) { const url = new URL(request.url); if (options.csrf_check_origin) { + const request_origin = request.headers.get('origin'); const forbidden = is_form_content_type(request) && (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH' || request.method === 'DELETE') && - request.headers.get('origin') !== url.origin; + request_origin !== url.origin && + (!request_origin || !options.csrf_allowed_origins.includes(request_origin)); if (forbidden) { const csrf_error = new HttpError( diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 17e2425e3c17..06f447ad439e 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -417,6 +417,7 @@ export interface SSROptions { app_template_contains_nonce: boolean; csp: ValidatedConfig['kit']['csp']; csrf_check_origin: boolean; + csrf_allowed_origins: string[]; embedded: boolean; env_public_prefix: string; env_private_prefix: string; diff --git a/packages/kit/test/apps/basics/src/routes/csrf/+server.js b/packages/kit/test/apps/basics/src/routes/csrf/+server.js index cfbef14241d6..a6e0c58d0b7a 100644 --- a/packages/kit/test/apps/basics/src/routes/csrf/+server.js +++ b/packages/kit/test/apps/basics/src/routes/csrf/+server.js @@ -1,4 +1,24 @@ +/** @type {import('./$types').RequestHandler} */ +export function GET() { + return new Response('ok', { status: 200 }); +} + /** @type {import('./$types').RequestHandler} */ export function POST() { - return new Response(undefined, { status: 201 }); + return new Response('ok', { status: 200 }); +} + +/** @type {import('./$types').RequestHandler} */ +export function PUT() { + return new Response('ok', { status: 200 }); +} + +/** @type {import('./$types').RequestHandler} */ +export function PATCH() { + return new Response('ok', { status: 200 }); +} + +/** @type {import('./$types').RequestHandler} */ +export function DELETE() { + return new Response('ok', { status: 200 }); } diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index d2193940f0ab..defc1c5afea0 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -18,6 +18,11 @@ const config = { } }, + csrf: { + checkOrigin: true, + allowedOrigins: ['https://trusted.example.com', 'https://payment-gateway.test'] + }, + prerender: { entries: [ '*', diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index e265115c53c2..2e976bc429a6 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -84,6 +84,127 @@ test.describe('CSRF', () => { } } }); + + test('Allows requests from same origin', async ({ baseURL }) => { + const url = new URL(baseURL); + const res = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: url.origin + } + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); + + test('Allows requests from allowed origins', async ({ baseURL }) => { + // Test with trusted.example.com which is in allowedOrigins + const res1 = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://trusted.example.com' + } + }); + expect(res1.status).toBe(200); + expect(await res1.text()).toBe('ok'); + + // Test with payment-gateway.test which is also in allowedOrigins + const res2 = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://payment-gateway.test' + } + }); + expect(res2.status).toBe(200); + expect(await res2.text()).toBe('ok'); + }); + + test('Blocks requests from non-allowed origins', async ({ baseURL }) => { + // Test with origin not in allowedOrigins list + const res1 = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://malicious-site.com' + } + }); + expect(res1.status).toBe(403); + expect(await res1.text()).toBe('Cross-site POST form submissions are forbidden'); + + // Test with similar but not exact origin + const res2 = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://trusted.example.com.evil.com' + } + }); + expect(res2.status).toBe(403); + expect(await res2.text()).toBe('Cross-site POST form submissions are forbidden'); + + // Test subdomain attack (should be blocked) + const res3 = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://evil.trusted.example.com' + } + }); + expect(res3.status).toBe(403); + expect(await res3.text()).toBe('Cross-site POST form submissions are forbidden'); + }); + + test('Allows GET requests regardless of origin', async ({ baseURL }) => { + const res = await fetch(`${baseURL}/csrf`, { + method: 'GET', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://any-origin.com' + } + }); + expect(res.status).toBe(200); + }); + + test('Allows non-form content types regardless of origin', async ({ baseURL }) => { + const res = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'https://any-origin.com' + } + }); + expect(res.status).toBe(200); + }); + + test('Allows all protected HTTP methods from allowed origins', async ({ baseURL }) => { + const methods = ['POST', 'PUT', 'PATCH', 'DELETE']; + for (const method of methods) { + const res = await fetch(`${baseURL}/csrf`, { + method, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://trusted.example.com' + } + }); + expect(res.status, `Method ${method} should be allowed from trusted origin`).toBe(200); + expect(await res.text(), `Method ${method} should return ok`).toBe('ok'); + } + }); + + test('Handles undefined origin correctly', async ({ baseURL }) => { + // Some requests may have null origin (e.g., from certain mobile apps) + const res = await fetch(`${baseURL}/csrf`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }); + expect(res.status).toBe(403); + expect(await res.text()).toBe('Cross-site POST form submissions are forbidden'); + }); }); test.describe('Endpoints', () => { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 28a0b6c4a56a..6e078054a3f6 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -355,6 +355,17 @@ declare module '@sveltejs/kit' { * @default true */ checkOrigin?: boolean; + /** + * An array of origins that are allowed to make cross-origin form submissions to your app, even when `checkOrigin` is `true`. + * + * Each origin should be a complete origin including protocol (e.g., `https://payment-gateway.com`). + * This is useful for allowing trusted third-party services like payment gateways or authentication providers to submit forms to your app. + * + * **Warning**: Only add origins you completely trust, as this bypasses CSRF protection for those origins. + * @default [] + * @example ['https://checkout.stripe.com', 'https://accounts.google.com'] + */ + allowedOrigins?: string[]; }; /** * Whether or not the app is embedded inside a larger app. If `true`, SvelteKit will add its event listeners related to navigation etc on the parent of `%sveltekit.body%` instead of `window`, and will pass `params` from the server rather than inferring them from `location.pathname`.