Skip to content

Commit 2e53b5f

Browse files
feat: add origin check for CSRF protection (#10678)
* feat: add origin check for CSRF protection * add tests * chore: documentation * changeset and grammar * chore: add casing check * split function * better naming * make the whole object experimental * remove unused type * update changeset * manually apply Sarah's suggestions * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <[email protected]> --------- Co-authored-by: Sarah Rainsberger <[email protected]>
1 parent ba3af20 commit 2e53b5f

File tree

14 files changed

+400
-0
lines changed

14 files changed

+400
-0
lines changed

.changeset/fair-jars-behave.md

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"astro": minor
3+
---
4+
5+
Adds a new experimental security option to prevent [Cross-Site Request Forgery (CSRF) attacks](https://owasp.org/www-community/attacks/csrf). This feature is available only for pages rendered on demand:
6+
7+
```js
8+
import { defineConfig } from "astro/config"
9+
export default defineConfig({
10+
experimental: {
11+
security: {
12+
csrfProtection: {
13+
origin: true
14+
}
15+
}
16+
}
17+
})
18+
```
19+
20+
Enabling this setting performs a check that the "origin" header, automatically passed by all modern browsers, matches the URL sent by each `Request`.
21+
22+
This experimental "origin" check is executed only for pages rendered on demand, and only for the requests `POST, `PATCH`, `DELETE` and `PUT` with one of the following `content-type` headers: 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'.
23+
24+
It the "origin" header doesn't match the pathname of the request, Astro will return a 403 status code and won't render the page.

packages/astro/src/@types/astro.ts

+56
Original file line numberDiff line numberDiff line change
@@ -1821,6 +1821,62 @@ export interface AstroUserConfig {
18211821
* See the [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains-experimental) for more details, including the limitations of this experimental feature.
18221822
*/
18231823
i18nDomains?: boolean;
1824+
1825+
/**
1826+
* @docs
1827+
* @name experimental.security
1828+
* @type {boolean}
1829+
* @default `false`
1830+
* @version 4.6.0
1831+
* @description
1832+
*
1833+
* Enables CSRF protection for Astro websites.
1834+
*
1835+
* The CSRF protection works only for pages rendered on demand (SSR) using `server` or `hybrid` mode. The pages must opt out of prerendering in `hybrid` mode.
1836+
*
1837+
* ```js
1838+
* // astro.config.mjs
1839+
* export default defineConfig({
1840+
* output: "server",
1841+
* experimental: {
1842+
* security: {
1843+
* csrfProtection: {
1844+
* origin: true
1845+
* }
1846+
* }
1847+
* }
1848+
* })
1849+
* ```
1850+
*/
1851+
security?: {
1852+
/**
1853+
* @name security.csrfProtection
1854+
* @type {object}
1855+
* @default '{}'
1856+
* @version 4.6.0
1857+
* @description
1858+
*
1859+
* Allows you to enable security measures to prevent CSRF attacks: https://owasp.org/www-community/attacks/csrf
1860+
*/
1861+
1862+
csrfProtection?: {
1863+
/**
1864+
* @name security.csrfProtection.origin
1865+
* @type {boolean}
1866+
* @default 'false'
1867+
* @version 4.6.0
1868+
* @description
1869+
*
1870+
* When enabled, performs a check that the "origin" header, automatically passed by all modern browsers, matches the URL sent by each `Request`.
1871+
*
1872+
* The "origin" check is executed only for pages rendered on demand, and only for the requests `POST, `PATCH`, `DELETE` and `PUT` with
1873+
* the following `content-type` header: 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'.
1874+
*
1875+
* If the "origin" header doesn't match the `pathname` of the request, Astro will return a 403 status code and will not render the page.
1876+
*/
1877+
origin?: boolean;
1878+
};
1879+
};
18241880
};
18251881
}
18261882

packages/astro/src/core/app/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { createAssetLink } from '../render/ssr-element.js';
3131
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
3232
import { matchRoute } from '../routing/match.js';
3333
import { AppPipeline } from './pipeline.js';
34+
import { sequence } from '../middleware/index.js';
35+
import { createOriginCheckMiddleware } from './middlewares.js';
3436
export { deserializeManifest } from './common.js';
3537

3638
export interface RenderOptions {
@@ -112,6 +114,13 @@ export class App {
112114
* @private
113115
*/
114116
#createPipeline(streaming = false) {
117+
if (this.#manifest.checkOrigin) {
118+
this.#manifest.middleware = sequence(
119+
createOriginCheckMiddleware(),
120+
this.#manifest.middleware
121+
);
122+
}
123+
115124
return AppPipeline.create({
116125
logger: this.#logger,
117126
manifest: this.#manifest,
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { MiddlewareHandler } from '../../@types/astro.js';
2+
import { defineMiddleware } from '../middleware/index.js';
3+
4+
/**
5+
* Content types that can be passed when sending a request via a form
6+
*
7+
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype
8+
* @private
9+
*/
10+
const FORM_CONTENT_TYPES = [
11+
'application/x-www-form-urlencoded',
12+
'multipart/form-data',
13+
'text/plain',
14+
];
15+
16+
/**
17+
* Returns a middleware function in charge to check the `origin` header.
18+
*
19+
* @private
20+
*/
21+
export function createOriginCheckMiddleware(): MiddlewareHandler {
22+
return defineMiddleware((context, next) => {
23+
const { request, url } = context;
24+
const contentType = request.headers.get('content-type');
25+
if (contentType) {
26+
if (FORM_CONTENT_TYPES.includes(contentType.toLowerCase())) {
27+
const forbidden =
28+
(request.method === 'POST' ||
29+
request.method === 'PUT' ||
30+
request.method === 'PATCH' ||
31+
request.method === 'DELETE') &&
32+
request.headers.get('origin') !== url.origin;
33+
if (forbidden) {
34+
return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
35+
status: 403,
36+
});
37+
}
38+
}
39+
}
40+
return next();
41+
});
42+
}

packages/astro/src/core/app/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type SSRManifest = {
6464
pageMap?: Map<ComponentPath, ImportComponentInstance>;
6565
i18n: SSRManifestI18n | undefined;
6666
middleware: MiddlewareHandler;
67+
checkOrigin: boolean;
6768
};
6869

6970
export type SSRManifestI18n = {

packages/astro/src/core/build/generate.ts

+1
Original file line numberDiff line numberDiff line change
@@ -615,5 +615,6 @@ function createBuildManifest(
615615
i18n: i18nManifest,
616616
buildFormat: settings.config.build.format,
617617
middleware,
618+
checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false,
618619
};
619620
}

packages/astro/src/core/build/plugins/plugin-manifest.ts

+1
Original file line numberDiff line numberDiff line change
@@ -276,5 +276,6 @@ function buildManifest(
276276
assets: staticFiles.map(prefixAssetPath),
277277
i18n: i18nManifest,
278278
buildFormat: settings.config.build.format,
279+
checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false,
279280
};
280281
}

packages/astro/src/core/config/schema.ts

+12
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const ASTRO_CONFIG_DEFAULTS = {
8686
clientPrerender: false,
8787
globalRoutePriority: false,
8888
i18nDomains: false,
89+
security: {},
8990
},
9091
} satisfies AstroUserConfig & { server: { open: boolean } };
9192

@@ -508,6 +509,17 @@ export const AstroConfigSchema = z.object({
508509
.boolean()
509510
.optional()
510511
.default(ASTRO_CONFIG_DEFAULTS.experimental.globalRoutePriority),
512+
security: z
513+
.object({
514+
csrfProtection: z
515+
.object({
516+
origin: z.boolean().default(false),
517+
})
518+
.optional()
519+
.default({}),
520+
})
521+
.optional()
522+
.default(ASTRO_CONFIG_DEFAULTS.experimental.security),
511523
i18nDomains: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.i18nDomains),
512524
})
513525
.strict(

packages/astro/src/vite-plugin-astro-server/plugin.ts

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
143143
componentMetadata: new Map(),
144144
inlinedScripts: new Map(),
145145
i18n: i18nManifest,
146+
checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false,
146147
middleware(_, next) {
147148
return next();
148149
},

0 commit comments

Comments
 (0)