summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/fair-jars-behave.md24
-rw-r--r--packages/astro/src/@types/astro.ts56
-rw-r--r--packages/astro/src/core/app/index.ts9
-rw-r--r--packages/astro/src/core/app/middlewares.ts42
-rw-r--r--packages/astro/src/core/app/types.ts1
-rw-r--r--packages/astro/src/core/build/generate.ts1
-rw-r--r--packages/astro/src/core/build/plugins/plugin-manifest.ts1
-rw-r--r--packages/astro/src/core/config/schema.ts12
-rw-r--r--packages/astro/src/vite-plugin-astro-server/plugin.ts1
-rw-r--r--packages/astro/test/csrf-protection.test.js196
-rw-r--r--packages/astro/test/fixtures/csrf-check-origin/astro.config.mjs14
-rw-r--r--packages/astro/test/fixtures/csrf-check-origin/package.json8
-rw-r--r--packages/astro/test/fixtures/csrf-check-origin/src/pages/api.ts29
-rw-r--r--pnpm-lock.yaml6
14 files changed, 400 insertions, 0 deletions
diff --git a/.changeset/fair-jars-behave.md b/.changeset/fair-jars-behave.md
new file mode 100644
index 000000000..700b1b883
--- /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 e39689a25..9d75bd84e 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 7c1480bd7..cb7a8d9db 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 000000000..095158b42
--- /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 2596ab3a6..e919e80e4 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<ComponentPath, ImportComponentInstance>;
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 d82ecdbd8..e7ddc6de9 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 24437d4e5..393442861 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 ef1a6ec85..58bea2f2b 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 b08bcb4eb..3d2889735 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 000000000..ab76a18f5
--- /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 000000000..af516bcd9
--- /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 000000000..1573627d8
--- /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 000000000..8aa35cc25
--- /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 68d54e563..6101a0bc5 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':