Skip to content

Commit bd7cd9f

Browse files
committed
feat: add origin check for CSRF protection
1 parent 0a407c4 commit bd7cd9f

File tree

7 files changed

+139
-0
lines changed

7 files changed

+139
-0
lines changed

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

+66
Original file line numberDiff line numberDiff line change
@@ -1610,6 +1610,49 @@ export interface AstroUserConfig {
16101610
*/
16111611
legacy?: object;
16121612

1613+
/**
1614+
* @name security
1615+
* @type {object}
1616+
* @version 4.6.0
1617+
* @type {object}
1618+
* @description
1619+
*
1620+
* It allows to opt-in various security measures for Astro applications.
1621+
*/
1622+
security?: {
1623+
/**
1624+
* @name security.csrfProtection
1625+
* @type {object}
1626+
* @default '{}'
1627+
* @version 4.6.0
1628+
* @description
1629+
*/
1630+
1631+
csrfProtection?: {
1632+
/**
1633+
* @name security.csrfProtection.origin
1634+
* @type {boolean}
1635+
* @default 'false'
1636+
* @version 4.6.0
1637+
* @description
1638+
*
1639+
* Something
1640+
*/
1641+
origin?: boolean;
1642+
1643+
/**
1644+
* @name security.csrfProtection.token
1645+
* @type {boolean}
1646+
* @default 'false'
1647+
* @version 4.6.0
1648+
* @description
1649+
*
1650+
* Something
1651+
*/
1652+
token?: boolean | string;
1653+
};
1654+
};
1655+
16131656
/**
16141657
* @docs
16151658
* @kind heading
@@ -1821,6 +1864,29 @@ export interface AstroUserConfig {
18211864
* See the [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains-experimental) for more details, including the limitations of this experimental feature.
18221865
*/
18231866
i18nDomains?: boolean;
1867+
1868+
/**
1869+
* @docs
1870+
* @name experimental.csrfProtection
1871+
* @type {boolean}
1872+
* @default `false`
1873+
* @version 4.6.0
1874+
* @description
1875+
*
1876+
* It enables the CSRF protection for Astro websites.
1877+
*
1878+
* The CSRF protection works only for on-demand pages (SSR) and hybrid pages where prerendering is opted out.
1879+
*
1880+
* ```js
1881+
* // astro.config.mjs
1882+
* export default defineConfig({
1883+
* experimental: {
1884+
* csrfProtection: true,
1885+
* },
1886+
* })
1887+
* ```
1888+
*/
1889+
csrfProtection?: boolean;
18241890
};
18251891
}
18261892

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

+45
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
ComponentInstance,
33
ManifestData,
4+
MiddlewareHandler,
45
RouteData,
56
SSRManifest,
67
} from '../../@types/astro.js';
@@ -31,6 +32,7 @@ import { createAssetLink } from '../render/ssr-element.js';
3132
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
3233
import { matchRoute } from '../routing/match.js';
3334
import { AppPipeline } from './pipeline.js';
35+
import { defineMiddleware, sequence } from '../middleware/index.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.csrfProtection) {
118+
this.#manifest.middleware = sequence(
119+
this.#createOriginCheckMiddleware(),
120+
this.#manifest.middleware
121+
);
122+
}
123+
115124
return AppPipeline.create({
116125
logger: this.#logger,
117126
manifest: this.#manifest,
@@ -137,6 +146,42 @@ export class App {
137146
});
138147
}
139148

149+
/**
150+
* Content types that can be passed when sending a request via a form
151+
*
152+
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype
153+
* @private
154+
*/
155+
#formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
156+
157+
/**
158+
* Returns a middleware function in charge to check the `origin` header.
159+
*
160+
* @private
161+
*/
162+
#createOriginCheckMiddleware(): MiddlewareHandler {
163+
return defineMiddleware((context, next) => {
164+
const { request, url } = context;
165+
const contentType = request.headers.get('content-type');
166+
if (contentType) {
167+
if (this.#formContentTypes.includes(contentType)) {
168+
const forbidden =
169+
(request.method === 'POST' ||
170+
request.method === 'PUT' ||
171+
request.method === 'PATCH' ||
172+
request.method === 'DELETE') &&
173+
request.headers.get('origin') !== url.origin;
174+
if (forbidden) {
175+
return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
176+
status: 403,
177+
});
178+
}
179+
}
180+
}
181+
return next();
182+
});
183+
}
184+
140185
set setManifestData(newManifestData: ManifestData) {
141186
this.#manifestData = newManifestData;
142187
}

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

+5
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+
csrfProtection: SSRCsrfProtection | undefined;
6768
};
6869

6970
export type SSRManifestI18n = {
@@ -74,6 +75,10 @@ export type SSRManifestI18n = {
7475
domainLookupTable: Record<string, string>;
7576
};
7677

78+
export type SSRCsrfProtection = {
79+
origin: boolean;
80+
};
81+
7782
export type SerializedSSRManifest = Omit<
7883
SSRManifest,
7984
'middleware' | 'routes' | 'assets' | 'componentMetadata' | 'inlinedScripts' | 'clientDirectives'

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

+3
Original file line numberDiff line numberDiff line change
@@ -615,5 +615,8 @@ function createBuildManifest(
615615
i18n: i18nManifest,
616616
buildFormat: settings.config.build.format,
617617
middleware,
618+
csrfProtection: settings.config.experimental.csrfProtection
619+
? settings.config.security?.csrfProtection
620+
: undefined,
618621
};
619622
}

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

+3
Original file line numberDiff line numberDiff line change
@@ -276,5 +276,8 @@ function buildManifest(
276276
assets: staticFiles.map(prefixAssetPath),
277277
i18n: i18nManifest,
278278
buildFormat: settings.config.build.format,
279+
csrfProtection: settings.config.experimental.csrfProtection
280+
? settings.config.security?.csrfProtection
281+
: undefined,
279282
};
280283
}

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

+14
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+
csrfProtection: false,
8990
},
9091
} satisfies AstroUserConfig & { server: { open: boolean } };
9192

@@ -139,6 +140,15 @@ export const AstroConfigSchema = z.object({
139140
.array(z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }))
140141
.default(ASTRO_CONFIG_DEFAULTS.integrations)
141142
),
143+
security: z
144+
.object({
145+
csrfProtection: z
146+
.object({
147+
origin: z.boolean().default(false),
148+
})
149+
.optional(),
150+
})
151+
.optional(),
142152
build: z
143153
.object({
144154
format: z
@@ -508,6 +518,10 @@ export const AstroConfigSchema = z.object({
508518
.boolean()
509519
.optional()
510520
.default(ASTRO_CONFIG_DEFAULTS.experimental.globalRoutePriority),
521+
csrfProtection: z
522+
.boolean()
523+
.optional()
524+
.default(ASTRO_CONFIG_DEFAULTS.experimental.csrfProtection),
511525
i18nDomains: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.i18nDomains),
512526
})
513527
.strict(

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

+3
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
143143
componentMetadata: new Map(),
144144
inlinedScripts: new Map(),
145145
i18n: i18nManifest,
146+
csrfProtection: settings.config.experimental.csrfProtection
147+
? settings.config.security?.csrfProtection
148+
: undefined,
146149
middleware(_, next) {
147150
return next();
148151
},

0 commit comments

Comments
 (0)