diff --git a/.changeset/plain-games-roll.md b/.changeset/plain-games-roll.md new file mode 100644 index 000000000000..489d19746e2d --- /dev/null +++ b/.changeset/plain-games-roll.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `csrf.trustedOrigins` configuration diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 3c557a28a13b..3e923935059e 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, + trustedOrigins: [] }, embedded: false, env: { diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 0c1009cc77ba..2210130b148b 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -108,7 +108,8 @@ const options = object( }), csrf: object({ - checkOrigin: boolean(true) + checkOrigin: boolean(true), + trustedOrigins: 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 4c8a3a40e680..f23f23b268be 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_trusted_origins: ${s(config.kit.csrf.trustedOrigins)}, 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 c92f523a17b8..22df7f3c91e5 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -428,6 +428,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'] + */ + trustedOrigins?: 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 fb258c953b65..fe2ed1c6f86a 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -74,30 +74,31 @@ export async function internal_respond(request, options, manifest, state) { const is_data_request = has_data_suffix(url.pathname); const remote_id = get_remote_id(url); - if (options.csrf_check_origin && request.headers.get('origin') !== url.origin) { - const opts = { status: 403 }; - - if (remote_id && request.method !== 'GET') { - return json( - { - message: 'Cross-site remote requests are forbidden' - }, - opts - ); - } + const request_origin = request.headers.get('origin'); + if (remote_id) { + if (request.method !== 'GET' && request_origin !== url.origin) { + const message = 'Cross-site remote requests are forbidden'; + return json({ message }, { status: 403 }); + } + } else if (options.csrf_check_origin) { const forbidden = is_form_content_type(request) && (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH' || - request.method === 'DELETE'); + request.method === 'DELETE') && + request_origin !== url.origin && + (!request_origin || !options.csrf_trusted_origins.includes(request_origin)); if (forbidden) { const message = `Cross-site ${request.method} form submissions are forbidden`; + const opts = { status: 403 }; + if (request.headers.get('accept') === 'application/json') { return json({ message }, opts); } + return text(message, opts); } } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 24d7d2bd4a7b..9e215926ce1a 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -442,6 +442,7 @@ export interface SSROptions { app_template_contains_nonce: boolean; csp: ValidatedConfig['kit']['csp']; csrf_check_origin: boolean; + csrf_trusted_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 bb269565e7e1..fc40386676f9 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -37,6 +37,11 @@ const config = { } }, + csrf: { + checkOrigin: true, + trustedOrigins: ['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 a4075f0f60ad..5b14edb642a1 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -85,6 +85,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 trustedOrigins + 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 trustedOrigins + 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 trustedOrigins 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 6c7fc61b4492..07e62b12c88b 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -404,6 +404,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'] + */ + trustedOrigins?: 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`.