Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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,
trustedOrigins: []
},
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 @@ -108,7 +108,8 @@ const options = object(
}),

csrf: object({
checkOrigin: boolean(true)
checkOrigin: boolean(true),
trustedOrigins: 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_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}',
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 @@ -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`.
Expand Down
25 changes: 13 additions & 12 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
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 @@ -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;
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 @@ -37,6 +37,11 @@ const config = {
}
},

csrf: {
checkOrigin: true,
trustedOrigins: ['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 @@ -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', () => {
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 @@ -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`.
Expand Down
Loading