Skip to content

Commit

Permalink
feat: add origin check for CSRF protection (#10678)
Browse files Browse the repository at this point in the history
* 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 <sarah@rainsberger.ca>

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
ematipico and sarah11918 authored Apr 10, 2024
1 parent ba3af20 commit 2e53b5f
Show file tree
Hide file tree
Showing 14 changed files with 400 additions and 0 deletions.
24 changes: 24 additions & 0 deletions .changeset/fair-jars-behave.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
};
}

Expand Down
9 changes: 9 additions & 0 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions packages/astro/src/core/app/middlewares.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
1 change: 1 addition & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export type SSRManifest = {
pageMap?: Map<ComponentPath, ImportComponentInstance>;
i18n: SSRManifestI18n | undefined;
middleware: MiddlewareHandler;
checkOrigin: boolean;
};

export type SSRManifestI18n = {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,5 +615,6 @@ function createBuildManifest(
i18n: i18nManifest,
buildFormat: settings.config.build.format,
middleware,
checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false,
};
}
1 change: 1 addition & 0 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
12 changes: 12 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const ASTRO_CONFIG_DEFAULTS = {
clientPrerender: false,
globalRoutePriority: false,
i18nDomains: false,
security: {},
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/vite-plugin-astro-server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down
Loading

0 comments on commit 2e53b5f

Please sign in to comment.