0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat: add origin check for CSRF protection (#10678)

* 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>
This commit is contained in:
Emanuele Stoppa 2024-04-10 14:53:32 +01:00 committed by GitHub
parent ba3af206b5
commit 2e53b5fff6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 400 additions and 0 deletions

View file

@ -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.

View file

@ -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;
};
};
};
}

View file

@ -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,

View file

@ -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();
});
}

View file

@ -64,6 +64,7 @@ export type SSRManifest = {
pageMap?: Map<ComponentPath, ImportComponentInstance>;
i18n: SSRManifestI18n | undefined;
middleware: MiddlewareHandler;
checkOrigin: boolean;
};
export type SSRManifestI18n = {

View file

@ -615,5 +615,6 @@ function createBuildManifest(
i18n: i18nManifest,
buildFormat: settings.config.build.format,
middleware,
checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false,
};
}

View file

@ -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,
};
}

View file

@ -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(

View file

@ -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();
},

View file

@ -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',
});
});
});

View file

@ -0,0 +1,14 @@
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
output: "server",
experimental: {
security: {
csrfProtection: {
origin: true
}
}
}
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/csrf",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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',
});
};

View file

@ -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':