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:
parent
ba3af206b5
commit
2e53b5fff6
14 changed files with 400 additions and 0 deletions
24
.changeset/fair-jars-behave.md
Normal file
24
.changeset/fair-jars-behave.md
Normal 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.
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
42
packages/astro/src/core/app/middlewares.ts
Normal file
42
packages/astro/src/core/app/middlewares.ts
Normal 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();
|
||||
});
|
||||
}
|
|
@ -64,6 +64,7 @@ export type SSRManifest = {
|
|||
pageMap?: Map<ComponentPath, ImportComponentInstance>;
|
||||
i18n: SSRManifestI18n | undefined;
|
||||
middleware: MiddlewareHandler;
|
||||
checkOrigin: boolean;
|
||||
};
|
||||
|
||||
export type SSRManifestI18n = {
|
||||
|
|
|
@ -615,5 +615,6 @@ function createBuildManifest(
|
|||
i18n: i18nManifest,
|
||||
buildFormat: settings.config.build.format,
|
||||
middleware,
|
||||
checkOrigin: settings.config.experimental.security?.csrfProtection?.origin ?? false,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
|
|
196
packages/astro/test/csrf-protection.test.js
Normal file
196
packages/astro/test/csrf-protection.test.js
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
14
packages/astro/test/fixtures/csrf-check-origin/astro.config.mjs
vendored
Normal file
14
packages/astro/test/fixtures/csrf-check-origin/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
experimental: {
|
||||
security: {
|
||||
csrfProtection: {
|
||||
origin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
8
packages/astro/test/fixtures/csrf-check-origin/package.json
vendored
Normal file
8
packages/astro/test/fixtures/csrf-check-origin/package.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@test/csrf",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
29
packages/astro/test/fixtures/csrf-check-origin/src/pages/api.ts
vendored
Normal file
29
packages/astro/test/fixtures/csrf-check-origin/src/pages/api.ts
vendored
Normal 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',
|
||||
});
|
||||
};
|
|
@ -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':
|
||||
|
|
Loading…
Reference in a new issue