Skip to content

Commit bf95071

Browse files
khromovRich-Harris
andauthored
feat: add CSRF trustedOrigins bypass list (#14021)
* Add allowedOrigins option to CSRF config Introduces a new `allowedOrigins` array to the CSRF configuration, allowing trusted third-party origins to bypass CSRF origin checks for form submissions. Updates server logic to permit requests from these origins, extends type definitions and documentation, and adds comprehensive tests to verify correct behavior for allowed, blocked, and edge-case origins. * Update CSRF test for undefined origin handling Renames the test to clarify it checks for undefined origin and removes the 'origin: null' header from the request. This ensures the test accurately reflects scenarios where the origin header is not set. * format * Add allowedOrigins option to form CSRF config Introduces an allowedOrigins array to the form configuration, enabling trusted third-party origins to bypass CSRF protection for cross-origin form submissions. This is useful for integrating with services like payment gateways or authentication providers. * Add changeset * separate remote calls from form submissions * allowedOrigins -> trustedOrigins * regenerate * Apply suggestions from code review --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent d8f9198 commit bf95071

File tree

11 files changed

+193
-15
lines changed

11 files changed

+193
-15
lines changed

.changeset/plain-games-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: add `csrf.trustedOrigins` configuration

packages/kit/src/core/config/index.spec.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ const get_defaults = (prefix = '') => ({
6868
reportOnly: directive_defaults
6969
},
7070
csrf: {
71-
checkOrigin: true
71+
checkOrigin: true,
72+
trustedOrigins: []
7273
},
7374
embedded: false,
7475
env: {

packages/kit/src/core/config/options.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ const options = object(
108108
}),
109109

110110
csrf: object({
111-
checkOrigin: boolean(true)
111+
checkOrigin: boolean(true),
112+
trustedOrigins: string_array([])
112113
}),
113114

114115
embedded: boolean(false),

packages/kit/src/core/sync/write_server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const options = {
3838
app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
3939
csp: ${s(config.kit.csp)},
4040
csrf_check_origin: ${s(config.kit.csrf.checkOrigin)},
41+
csrf_trusted_origins: ${s(config.kit.csrf.trustedOrigins)},
4142
embedded: ${config.kit.embedded},
4243
env_public_prefix: '${config.kit.env.publicPrefix}',
4344
env_private_prefix: '${config.kit.env.privatePrefix}',

packages/kit/src/exports/public.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,17 @@ export interface KitConfig {
428428
* @default true
429429
*/
430430
checkOrigin?: boolean;
431+
/**
432+
* An array of origins that are allowed to make cross-origin form submissions to your app, even when `checkOrigin` is `true`.
433+
*
434+
* Each origin should be a complete origin including protocol (e.g., `https://payment-gateway.com`).
435+
* This is useful for allowing trusted third-party services like payment gateways or authentication providers to submit forms to your app.
436+
*
437+
* **Warning**: Only add origins you completely trust, as this bypasses CSRF protection for those origins.
438+
* @default []
439+
* @example ['https://checkout.stripe.com', 'https://accounts.google.com']
440+
*/
441+
trustedOrigins?: string[];
431442
};
432443
/**
433444
* 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`.

packages/kit/src/runtime/server/respond.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,30 +74,31 @@ export async function internal_respond(request, options, manifest, state) {
7474
const is_data_request = has_data_suffix(url.pathname);
7575
const remote_id = get_remote_id(url);
7676

77-
if (options.csrf_check_origin && request.headers.get('origin') !== url.origin) {
78-
const opts = { status: 403 };
79-
80-
if (remote_id && request.method !== 'GET') {
81-
return json(
82-
{
83-
message: 'Cross-site remote requests are forbidden'
84-
},
85-
opts
86-
);
87-
}
77+
const request_origin = request.headers.get('origin');
8878

79+
if (remote_id) {
80+
if (request.method !== 'GET' && request_origin !== url.origin) {
81+
const message = 'Cross-site remote requests are forbidden';
82+
return json({ message }, { status: 403 });
83+
}
84+
} else if (options.csrf_check_origin) {
8985
const forbidden =
9086
is_form_content_type(request) &&
9187
(request.method === 'POST' ||
9288
request.method === 'PUT' ||
9389
request.method === 'PATCH' ||
94-
request.method === 'DELETE');
90+
request.method === 'DELETE') &&
91+
request_origin !== url.origin &&
92+
(!request_origin || !options.csrf_trusted_origins.includes(request_origin));
9593

9694
if (forbidden) {
9795
const message = `Cross-site ${request.method} form submissions are forbidden`;
96+
const opts = { status: 403 };
97+
9898
if (request.headers.get('accept') === 'application/json') {
9999
return json({ message }, opts);
100100
}
101+
101102
return text(message, opts);
102103
}
103104
}

packages/kit/src/types/internal.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ export interface SSROptions {
442442
app_template_contains_nonce: boolean;
443443
csp: ValidatedConfig['kit']['csp'];
444444
csrf_check_origin: boolean;
445+
csrf_trusted_origins: string[];
445446
embedded: boolean;
446447
env_public_prefix: string;
447448
env_private_prefix: string;
Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1+
/** @type {import('./$types').RequestHandler} */
2+
export function GET() {
3+
return new Response('ok', { status: 200 });
4+
}
5+
16
/** @type {import('./$types').RequestHandler} */
27
export function POST() {
3-
return new Response(undefined, { status: 201 });
8+
return new Response('ok', { status: 200 });
9+
}
10+
11+
/** @type {import('./$types').RequestHandler} */
12+
export function PUT() {
13+
return new Response('ok', { status: 200 });
14+
}
15+
16+
/** @type {import('./$types').RequestHandler} */
17+
export function PATCH() {
18+
return new Response('ok', { status: 200 });
19+
}
20+
21+
/** @type {import('./$types').RequestHandler} */
22+
export function DELETE() {
23+
return new Response('ok', { status: 200 });
424
}

packages/kit/test/apps/basics/svelte.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ const config = {
3737
}
3838
},
3939

40+
csrf: {
41+
checkOrigin: true,
42+
trustedOrigins: ['https://trusted.example.com', 'https://payment-gateway.test']
43+
},
44+
4045
prerender: {
4146
entries: [
4247
'*',

packages/kit/test/apps/basics/test/server.test.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,127 @@ test.describe('CSRF', () => {
8585
}
8686
}
8787
});
88+
89+
test('Allows requests from same origin', async ({ baseURL }) => {
90+
const url = new URL(baseURL);
91+
const res = await fetch(`${baseURL}/csrf`, {
92+
method: 'POST',
93+
headers: {
94+
'content-type': 'application/x-www-form-urlencoded',
95+
origin: url.origin
96+
}
97+
});
98+
expect(res.status).toBe(200);
99+
expect(await res.text()).toBe('ok');
100+
});
101+
102+
test('Allows requests from allowed origins', async ({ baseURL }) => {
103+
// Test with trusted.example.com which is in trustedOrigins
104+
const res1 = await fetch(`${baseURL}/csrf`, {
105+
method: 'POST',
106+
headers: {
107+
'content-type': 'application/x-www-form-urlencoded',
108+
origin: 'https://trusted.example.com'
109+
}
110+
});
111+
expect(res1.status).toBe(200);
112+
expect(await res1.text()).toBe('ok');
113+
114+
// Test with payment-gateway.test which is also in trustedOrigins
115+
const res2 = await fetch(`${baseURL}/csrf`, {
116+
method: 'POST',
117+
headers: {
118+
'content-type': 'application/x-www-form-urlencoded',
119+
origin: 'https://payment-gateway.test'
120+
}
121+
});
122+
expect(res2.status).toBe(200);
123+
expect(await res2.text()).toBe('ok');
124+
});
125+
126+
test('Blocks requests from non-allowed origins', async ({ baseURL }) => {
127+
// Test with origin not in trustedOrigins list
128+
const res1 = await fetch(`${baseURL}/csrf`, {
129+
method: 'POST',
130+
headers: {
131+
'content-type': 'application/x-www-form-urlencoded',
132+
origin: 'https://malicious-site.com'
133+
}
134+
});
135+
expect(res1.status).toBe(403);
136+
expect(await res1.text()).toBe('Cross-site POST form submissions are forbidden');
137+
138+
// Test with similar but not exact origin
139+
const res2 = await fetch(`${baseURL}/csrf`, {
140+
method: 'POST',
141+
headers: {
142+
'content-type': 'application/x-www-form-urlencoded',
143+
origin: 'https://trusted.example.com.evil.com'
144+
}
145+
});
146+
expect(res2.status).toBe(403);
147+
expect(await res2.text()).toBe('Cross-site POST form submissions are forbidden');
148+
149+
// Test subdomain attack (should be blocked)
150+
const res3 = await fetch(`${baseURL}/csrf`, {
151+
method: 'POST',
152+
headers: {
153+
'content-type': 'application/x-www-form-urlencoded',
154+
origin: 'https://evil.trusted.example.com'
155+
}
156+
});
157+
expect(res3.status).toBe(403);
158+
expect(await res3.text()).toBe('Cross-site POST form submissions are forbidden');
159+
});
160+
161+
test('Allows GET requests regardless of origin', async ({ baseURL }) => {
162+
const res = await fetch(`${baseURL}/csrf`, {
163+
method: 'GET',
164+
headers: {
165+
'content-type': 'application/x-www-form-urlencoded',
166+
origin: 'https://any-origin.com'
167+
}
168+
});
169+
expect(res.status).toBe(200);
170+
});
171+
172+
test('Allows non-form content types regardless of origin', async ({ baseURL }) => {
173+
const res = await fetch(`${baseURL}/csrf`, {
174+
method: 'POST',
175+
headers: {
176+
'content-type': 'application/json',
177+
origin: 'https://any-origin.com'
178+
}
179+
});
180+
expect(res.status).toBe(200);
181+
});
182+
183+
test('Allows all protected HTTP methods from allowed origins', async ({ baseURL }) => {
184+
const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
185+
for (const method of methods) {
186+
const res = await fetch(`${baseURL}/csrf`, {
187+
method,
188+
headers: {
189+
'content-type': 'application/x-www-form-urlencoded',
190+
origin: 'https://trusted.example.com'
191+
}
192+
});
193+
expect(res.status, `Method ${method} should be allowed from trusted origin`).toBe(200);
194+
expect(await res.text(), `Method ${method} should return ok`).toBe('ok');
195+
}
196+
});
197+
198+
test('Handles undefined origin correctly', async ({ baseURL }) => {
199+
// Some requests may have null origin (e.g., from certain mobile apps)
200+
const res = await fetch(`${baseURL}/csrf`, {
201+
method: 'POST',
202+
headers: {
203+
'content-type': 'application/x-www-form-urlencoded'
204+
}
205+
});
206+
expect(res.status).toBe(403);
207+
expect(await res.text()).toBe('Cross-site POST form submissions are forbidden');
208+
});
88209
});
89210

90211
test.describe('Endpoints', () => {

0 commit comments

Comments
 (0)