Skip to content

Commit 164cddb

Browse files
Block cross-site form POSTs by default (#6510)
* CSRF protection by default * changeset * fix tests * POST alone * restrict to form submissions * fix content-type check * Update .changeset/strange-apples-vanish.md Co-authored-by: Conduitry <[email protected]> * undo test changes * revert formatting change * blurgh Co-authored-by: Conduitry <[email protected]>
1 parent 768369f commit 164cddb

File tree

11 files changed

+69
-0
lines changed

11 files changed

+69
-0
lines changed

.changeset/strange-apples-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
[breaking] block cross-site form POSTs by default. disable with config.kit.csrf.checkOrigin

documentation/docs/14-configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const config = {
2525
// ...
2626
}
2727
},
28+
csrf: {
29+
checkOrigin: true
30+
},
2831
env: {
2932
dir: process.cwd(),
3033
publicPrefix: 'PUBLIC_'
@@ -161,6 +164,14 @@ When pages are prerendered, the CSP header is added via a `<meta http-equiv>` ta
161164
162165
> Note that most [Svelte transitions](https://svelte.dev/tutorial/transition) work by creating an inline `<style>` element. If you use these in your app, you must either leave the `style-src` directive unspecified or add `unsafe-inline`.
163166
167+
### csrf
168+
169+
Protection against [cross-site request forgery](https://owasp.org/www-community/attacks/csrf) attacks:
170+
171+
- `checkOrigin` — if `true`, SvelteKit will check the incoming `origin` header for `POST` form submissions and verify that it matches the server's origin
172+
173+
To allow people to make `POST` form submissions to your app from other origins, you will need to disable this option. Be careful!
174+
164175
### env
165176

166177
Environment variable configuration:

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ const get_defaults = (prefix = '') => ({
7272
directives: directive_defaults,
7373
reportOnly: directive_defaults
7474
},
75+
csrf: {
76+
checkOrigin: true
77+
},
7578
endpointExtensions: undefined,
7679
env: {
7780
dir: process.cwd(),

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ const options = object(
125125
reportOnly: directives
126126
}),
127127

128+
csrf: object({
129+
checkOrigin: boolean(true)
130+
}),
131+
128132
// TODO: remove this for the 1.0 release
129133
endpointExtensions: error(
130134
(keypath) => `${keypath} has been renamed to config.kit.moduleExtensions`

packages/kit/src/exports/vite/build/build_server.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export class Server {
5454
constructor(manifest) {
5555
this.options = {
5656
csp: ${s(config.kit.csp)},
57+
csrf: {
58+
check_origin: ${s(config.kit.csrf.checkOrigin)},
59+
},
5760
dev: false,
5861
get_stack: error => String(error), // for security
5962
handle_error: (error, event) => {

packages/kit/src/exports/vite/dev/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) {
377377
request,
378378
{
379379
csp: svelte_config.kit.csp,
380+
csrf: {
381+
check_origin: svelte_config.kit.csrf.checkOrigin
382+
},
380383
dev: true,
381384
get_stack: (error) => fix_stack_trace(error),
382385
handle_error: (error, event) => {

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ const default_transform = ({ html }) => html;
1818
export async function respond(request, options, state) {
1919
let url = new URL(request.url);
2020

21+
if (options.csrf.check_origin) {
22+
const type = request.headers.get('content-type')?.split(';')[0];
23+
24+
const forbidden =
25+
request.method === 'POST' &&
26+
request.headers.get('origin') !== url.origin &&
27+
(type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data');
28+
29+
if (forbidden) {
30+
return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
31+
status: 403
32+
});
33+
}
34+
}
35+
2136
const { parameter, allowed } = options.method_override;
2237
const method_override = url.searchParams.get(parameter)?.toUpperCase();
2338

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** @type {import('./$types').RequestHandler} */
2+
export function POST() {
3+
return new Response(undefined, { status: 201 });
4+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect } from '@playwright/test';
22
import { test } from '../../../utils.js';
3+
import { fetch } from 'undici';
34
import { createHash, randomBytes } from 'node:crypto';
45

56
/** @typedef {import('@playwright/test').Response} Response */
@@ -22,6 +23,20 @@ test.describe('Content-Type', () => {
2223
});
2324
});
2425

26+
test.describe('CSRF', () => {
27+
test('Blocks requests with incorrect origin', async ({ baseURL }) => {
28+
const res = await fetch(`${baseURL}/csrf`, {
29+
method: 'POST',
30+
headers: {
31+
'content-type': 'application/x-www-form-urlencoded'
32+
}
33+
});
34+
35+
expect(res.status).toBe(403);
36+
expect(await res.text()).toBe('Cross-site POST form submissions are forbidden');
37+
});
38+
});
39+
2540
test.describe('Endpoints', () => {
2641
test('HEAD with matching headers but without body', async ({ request }) => {
2742
const url = '/endpoint-output/body';

packages/kit/types/index.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ export interface KitConfig {
128128
directives?: CspDirectives;
129129
reportOnly?: CspDirectives;
130130
};
131+
csrf?: {
132+
checkOrigin?: boolean;
133+
};
131134
env?: {
132135
dir?: string;
133136
publicPrefix?: string;

packages/kit/types/internal.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,9 @@ export type SSRNodeLoader = () => Promise<SSRNode>;
290290

291291
export interface SSROptions {
292292
csp: ValidatedConfig['kit']['csp'];
293+
csrf: {
294+
check_origin: boolean;
295+
};
293296
dev: boolean;
294297
get_stack: (error: Error) => string | undefined;
295298
handle_error(error: Error & { frame?: string }, event: RequestEvent): void;

0 commit comments

Comments
 (0)