diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 010815695b203..94c96159e99a8 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1821,6 +1821,18 @@ 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.csrfProtection + * @type {boolean} + * @default `false` + * @version 4.6.0 + * @description + * + * TODO + */ + csrfProtection?: boolean; }; } diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 7c1480bd76626..afee2f971eff2 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -1,6 +1,7 @@ import type { ComponentInstance, ManifestData, + MiddlewareHandler, RouteData, SSRManifest, } from '../../@types/astro.js'; @@ -31,6 +32,7 @@ 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 { defineMiddleware, sequence } from '../middleware/index.js'; export { deserializeManifest } from './common.js'; export interface RenderOptions { @@ -112,6 +114,13 @@ export class App { * @private */ #createPipeline(streaming = false) { + if (this.#manifest.csrfProtection) { + this.#manifest.middleware = sequence( + this.#createOriginCheckMiddleware(), + this.#manifest.middleware + ); + } + return AppPipeline.create({ logger: this.#logger, manifest: this.#manifest, @@ -137,6 +146,37 @@ export class App { }); } + /** + * 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 + */ + #formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain']; + + #createOriginCheckMiddleware(): MiddlewareHandler { + return defineMiddleware((context, next) => { + const { request, url } = context; + const contentType = request.headers.get('content-type'); + if (contentType) { + if (this.#formContentTypes.includes(contentType)) { + 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(); + }); + } + set setManifestData(newManifestData: ManifestData) { this.#manifestData = newManifestData; } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 2596ab3a69f20..f36d39e811120 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; + csrfProtection: SSRCsrfProtection | undefined; }; export type SSRManifestI18n = { @@ -74,6 +75,10 @@ export type SSRManifestI18n = { domainLookupTable: Record; }; +export type SSRCsrfProtection = { + origin: boolean; +}; + export type SerializedSSRManifest = Omit< SSRManifest, 'middleware' | 'routes' | 'assets' | 'componentMetadata' | 'inlinedScripts' | 'clientDirectives' diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index f98eb4992070b..05bd32d192234 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -17,7 +17,7 @@ import { RouteCache } from './render/route-cache.js'; * Thus, a `Pipeline` is created once at process start and then used by every `RenderContext`. */ export abstract class Pipeline { - readonly internalMiddleware: MiddlewareHandler[]; + protected internalMiddleware: MiddlewareHandler[]; constructor( readonly logger: Logger, diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 7dc00073fc5bf..aaff8924f5fd4 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -615,5 +615,8 @@ function createBuildManifest( i18n: i18nManifest, buildFormat: settings.config.build.format, middleware, + csrfProtection: settings.config.experimental.csrfProtection + ? settings.config.security?.csrfProtection + : undefined, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 24437d4e57658..5e23f42f8cb84 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -276,5 +276,8 @@ function buildManifest( assets: staticFiles.map(prefixAssetPath), i18n: i18nManifest, buildFormat: settings.config.build.format, + csrfProtection: settings.config.experimental.csrfProtection + ? settings.config.security?.csrfProtection + : undefined, }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index ef1a6ec85d67f..5d67073bf82cd 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, + csrfProtection: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -139,6 +140,15 @@ export const AstroConfigSchema = z.object({ .array(z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) })) .default(ASTRO_CONFIG_DEFAULTS.integrations) ), + security: z + .object({ + csrfProtection: z + .object({ + origin: z.boolean().default(false), + }) + .optional(), + }) + .optional(), build: z .object({ format: z @@ -508,6 +518,10 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.globalRoutePriority), + csrfProtection: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.csrfProtection), 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 b08bcb4ebd57f..8484483a3f055 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -143,6 +143,9 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest componentMetadata: new Map(), inlinedScripts: new Map(), i18n: i18nManifest, + csrfProtection: settings.config.experimental.csrfProtection + ? settings.config.security?.csrfProtection + : undefined, middleware(_, next) { return next(); },