Skip to content

feat: add CSRF allowedOrigins bypass list #14021

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plain-games-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

feat: add CSRF allowedOrigins bypass list
3 changes: 2 additions & 1 deletion packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ const get_defaults = (prefix = '') => ({
reportOnly: directive_defaults
},
csrf: {
checkOrigin: true
checkOrigin: true,
allowedOrigins: []
},
embedded: false,
env: {
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ const options = object(
}),

csrf: object({
checkOrigin: boolean(true)
checkOrigin: boolean(true),
allowedOrigins: string_array([])
}),

embedded: boolean(false),
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand Down
11 changes: 11 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion packages/kit/test/apps/basics/src/routes/csrf/+server.js
Original file line number Diff line number Diff line change
@@ -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 });
}
5 changes: 5 additions & 0 deletions packages/kit/test/apps/basics/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const config = {
}
},

csrf: {
checkOrigin: true,
allowedOrigins: ['https://trusted.example.com', 'https://payment-gateway.test']
},

prerender: {
entries: [
'*',
Expand Down
121 changes: 121 additions & 0 deletions packages/kit/test/apps/basics/test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading