diff --git a/.changeset/fair-jars-behave.md b/.changeset/fair-jars-behave.md new file mode 100644 index 000000000000..700b1b883021 --- /dev/null +++ b/.changeset/fair-jars-behave.md @@ -0,0 +1,24 @@ +--- +"astro": minor +--- + +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: + +```js +import { defineConfig } from "astro/config" +export default defineConfig({ + experimental: { + security: { + csrfProtection: { + origin: true + } + } + } +}) +``` + +Enabling this setting performs a check that the "origin" header, automatically passed by all modern browsers, matches the URL sent by each `Request`. + +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'. + +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. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index e39689a25b75..9d75bd84e1b6 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1821,6 +1821,62 @@ export interface AstroUserConfig { * See the [Internationalization Guide](https://docs.astro.build/en/guides/internationalization/#domains-experimental) for more details, including the limitations of this experimental feature. */ i18nDomains?: boolean; + + /** + * @docs + * @name experimental.security + * @type {boolean} + * @default `false` + * @version 4.6.0 + * @description + * + * Enables CSRF protection for Astro websites. + * + * 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. + * + * ```js + * // astro.config.mjs + * export default defineConfig({ + * output: "server", + * experimental: { + * security: { + * csrfProtection: { + * origin: true + * } + * } + * } + * }) + * ``` + */ + security?: { + /** + * @name security.csrfProtection + * @type {object} + * @default '{}' + * @version 4.6.0 + * @description + * + * Allows you to enable security measures to prevent CSRF attacks: https://owasp.org/www-community/attacks/csrf + */ + + csrfProtection?: { + /** + * @name security.csrfProtection.origin + * @type {boolean} + * @default 'false' + * @version 4.6.0 + * @description + * + * When enabled, performs a check that the "origin" header, automatically passed by all modern browsers, matches the URL sent by each `Request`. + * + * The "origin" check is executed only for pages rendered on demand, and only for the requests `POST, `PATCH`, `DELETE` and `PUT` with + * the following `content-type` header: 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'. + * + * 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. + */ + origin?: boolean; + }; + }; }; } diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 7c1480bd7662..cb7a8d9db188 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -31,6 +31,8 @@ import { createAssetLink } from '../render/ssr-element.js'; import { ensure404Route } from '../routing/astro-designed-error-pages.js'; import { matchRoute } from '../routing/match.js'; import { AppPipeline } from './pipeline.js'; +import { sequence } from '../middleware/index.js'; +import { createOriginCheckMiddleware } from './middlewares.js'; export { deserializeManifest } from './common.js'; export interface RenderOptions { @@ -112,6 +114,13 @@ export class App { * @private */ #createPipeline(streaming = false) { + if (this.#manifest.checkOrigin) { + this.#manifest.middleware = sequence( + createOriginCheckMiddleware(), + this.#manifest.middleware + ); + } + return AppPipeline.create({ logger: this.#logger, manifest: this.#manifest, diff --git a/packages/astro/src/core/app/middlewares.ts b/packages/astro/src/core/app/middlewares.ts new file mode 100644 index 000000000000..095158b42ba3 --- /dev/null +++ b/packages/astro/src/core/app/middlewares.ts @@ -0,0 +1,42 @@ +import type { MiddlewareHandler } from '../../@types/astro.js'; +import { defineMiddleware } from '../middleware/index.js'; + +/** + * Content types that can be passed when sending a request via a form + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype + * @private + */ +const FORM_CONTENT_TYPES = [ + 'application/x-www-form-urlencoded', + 'multipart/form-data', + 'text/plain', +]; + +/** + * Returns a middleware function in charge to check the `origin` header. + * + * @private + */ +export function createOriginCheckMiddleware(): MiddlewareHandler { + return defineMiddleware((context, next) => { + const { request, url } = context; + const contentType = request.headers.get('content-type'); + if (contentType) { + if (FORM_CONTENT_TYPES.includes(contentType.toLowerCase())) { + const forbidden = + (request.method === 'POST' || + request.method === 'PUT' || + request.method === 'PATCH' || + request.method === 'DELETE') && + request.headers.get('origin') !== url.origin; + if (forbidden) { + return new Response(`Cross-site ${request.method} form submissions are forbidden`, { + status: 403, + }); + } + } + } + return next(); + }); +} diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 2596ab3a69f2..e919e80e456f 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -64,6 +64,7 @@ export type SSRManifest = { pageMap?: Map; i18n: SSRManifestI18n | undefined; middleware: MiddlewareHandler; + checkOrigin: boolean; }; export type SSRManifestI18n = { diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 7dc00073fc5b..c407de024ff5 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -615,5 +615,6 @@ function createBuildManifest( i18n: i18nManifest, buildFormat: settings.config.build.format, middleware, + checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 24437d4e5765..393442861d35 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -276,5 +276,6 @@ function buildManifest( assets: staticFiles.map(prefixAssetPath), i18n: i18nManifest, buildFormat: settings.config.build.format, + checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index ef1a6ec85d67..58bea2f2b8f7 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -86,6 +86,7 @@ const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, globalRoutePriority: false, i18nDomains: false, + security: {}, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -508,6 +509,17 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.globalRoutePriority), + security: z + .object({ + csrfProtection: z + .object({ + origin: z.boolean().default(false), + }) + .optional() + .default({}), + }) + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.security), i18nDomains: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.i18nDomains), }) .strict( diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index b08bcb4ebd57..3d2889735b0d 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -143,6 +143,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest componentMetadata: new Map(), inlinedScripts: new Map(), i18n: i18nManifest, + checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false, middleware(_, next) { return next(); }, diff --git a/packages/astro/test/csrf-protection.test.js b/packages/astro/test/csrf-protection.test.js new file mode 100644 index 000000000000..ab76a18f5b81 --- /dev/null +++ b/packages/astro/test/csrf-protection.test.js @@ -0,0 +1,196 @@ +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; +import assert from 'node:assert/strict'; + +describe('CSRF origin check', () => { + let app; + + before(async () => { + const fixture = await loadFixture({ + root: './fixtures/csrf-check-origin/', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it("return 403 when the origin doesn't match and calling a POST", async () => { + let request; + let response; + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'multipart/form-data' }, + method: 'POST', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + // case where content-type has different casing + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'MULTIPART/FORM-DATA' }, + method: 'POST', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'text/plain' }, + method: 'POST', + }); + response = await app.render(request); + assert.equal(response.status, 403); + }); + + it("return 403 when the origin doesn't match and calling a PUT", async () => { + let request; + let response; + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'multipart/form-data' }, + method: 'PUT', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'application/x-www-form-urlencoded' }, + method: 'PUT', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'text/plain' }, + method: 'PUT', + }); + response = await app.render(request); + assert.equal(response.status, 403); + }); + + it("return 403 when the origin doesn't match and calling a DELETE", async () => { + let request; + let response; + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'multipart/form-data' }, + method: 'DELETE', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'application/x-www-form-urlencoded' }, + method: 'DELETE', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'text/plain' }, + method: 'DELETE', + }); + response = await app.render(request); + assert.equal(response.status, 403); + }); + + it("return 403 when the origin doesn't match and calling a PATCH", async () => { + let request; + let response; + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'multipart/form-data' }, + method: 'PATCH', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'application/x-www-form-urlencoded' }, + method: 'PATCH', + }); + response = await app.render(request); + assert.equal(response.status, 403); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'text/plain' }, + method: 'PATCH', + }); + response = await app.render(request); + assert.equal(response.status, 403); + }); + + it("return a 200 when the origin doesn't match but calling a GET", async () => { + let request; + let response; + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'multipart/form-data' }, + method: 'GET', + }); + response = await app.render(request); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + something: 'true', + }); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'application/x-www-form-urlencoded' }, + method: 'GET', + }); + response = await app.render(request); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + something: 'true', + }); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://loreum.com', 'content-type': 'text/plain' }, + method: 'GET', + }); + response = await app.render(request); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + something: 'true', + }); + }); + + it('return 200 when calling POST/PUT/DELETE/PATCH with the correct origin', async () => { + let request; + let response; + request = new Request('http://example.com/api/', { + headers: { origin: 'http://example.com', 'content-type': 'multipart/form-data' }, + method: 'POST', + }); + response = await app.render(request); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + something: 'true', + }); + + request = new Request('http://example.com/api/', { + headers: { + origin: 'http://example.com', + 'content-type': 'application/x-www-form-urlencoded', + }, + method: 'PUT', + }); + response = await app.render(request); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + something: 'true', + }); + + request = new Request('http://example.com/api/', { + headers: { origin: 'http://example.com', 'content-type': 'text/plain' }, + method: 'PATCH', + }); + response = await app.render(request); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + something: 'true', + }); + }); +}); diff --git a/packages/astro/test/fixtures/csrf-check-origin/astro.config.mjs b/packages/astro/test/fixtures/csrf-check-origin/astro.config.mjs new file mode 100644 index 000000000000..af516bcd9736 --- /dev/null +++ b/packages/astro/test/fixtures/csrf-check-origin/astro.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + output: "server", + experimental: { + security: { + csrfProtection: { + origin: true + } + } + } +}); + diff --git a/packages/astro/test/fixtures/csrf-check-origin/package.json b/packages/astro/test/fixtures/csrf-check-origin/package.json new file mode 100644 index 000000000000..1573627d8d4e --- /dev/null +++ b/packages/astro/test/fixtures/csrf-check-origin/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/csrf", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/csrf-check-origin/src/pages/api.ts b/packages/astro/test/fixtures/csrf-check-origin/src/pages/api.ts new file mode 100644 index 000000000000..8aa35cc25859 --- /dev/null +++ b/packages/astro/test/fixtures/csrf-check-origin/src/pages/api.ts @@ -0,0 +1,29 @@ +export const GET = () => { + return Response.json({ + something: 'true', + }); +}; + +export const POST = () => { + return Response.json({ + something: 'true', + }); +}; + +export const PUT = () => { + return Response.json({ + something: 'true', + }); +}; + +export const DELETE = () => { + return Response.json({ + something: 'true', + }); +}; + +export const PATCH = () => { + return Response.json({ + something: 'true', + }); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b97f52944eeb..dc75d4c94635 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2559,6 +2559,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/csrf-check-origin: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/css-assets: dependencies: '@test/astro-font-awesome-package':