refactor: move CSRF code into plugin (#56447)

This commit is contained in:
Oliver Eyton-Williams
2024-10-04 14:56:04 +02:00
committed by GitHub
parent 86527f448c
commit ced457fed5
8 changed files with 167 additions and 104 deletions

View File

@@ -7,6 +7,7 @@ import {
COOKIE_SECRET,
FREECODECAMP_NODE_ENV
} from '../utils/env';
import { CSRF_COOKIE, CSRF_SECRET_COOKIE } from './csrf';
export { type CookieSerializeOptions } from '@fastify/cookie';
@@ -68,8 +69,8 @@ const cookies: FastifyPluginCallback = (fastify, _options, done) => {
void fastify.decorateReply('clearOurCookies', function () {
void this.clearCookie('jwt_access_token');
void this.clearCookie('_csrf');
void this.clearCookie('csrf_token');
void this.clearCookie(CSRF_SECRET_COOKIE);
void this.clearCookie(CSRF_COOKIE);
});
done();

View File

@@ -0,0 +1,107 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { COOKIE_DOMAIN } from '../utils/env';
import cookies from './cookies';
import csrf, { CSRF_COOKIE, CSRF_SECRET_COOKIE } from './csrf';
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
jest.mock('../utils/env', () => ({
...jest.requireActual('../utils/env'),
COOKIE_DOMAIN: 'www.example.com',
FREECODECAMP_NODE_ENV: 'production'
}));
async function setupServer() {
const fastify = Fastify();
await fastify.register(cookies);
await fastify.register(csrf);
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.get('/', (_req, reply) => {
void reply.send({ foo: 'bar' });
});
return fastify;
}
describe('CSRF protection', () => {
let fastify: FastifyInstance;
beforeEach(async () => {
fastify = await setupServer();
});
it('should receive a new CSRF token with the expected properties', async () => {
const response = await fastify.inject({
method: 'GET',
url: '/'
});
const newCookies = response.cookies;
const csrfTokenCookie = newCookies.find(
cookie => cookie.name === CSRF_COOKIE
);
const { value, ...rest } = csrfTokenCookie!;
// The value is a random string - it's enough to check that it's not empty
expect(value).toHaveLength(52);
expect(rest).toStrictEqual({
name: CSRF_COOKIE,
path: '/',
sameSite: 'Strict',
domain: COOKIE_DOMAIN,
secure: true
});
});
it('should return 403 if the _csrf secret is missing', async () => {
const response = await fastify.inject({
method: 'GET',
url: '/'
});
expect(response.statusCode).toEqual(403);
// The response body is determined by the error-handling plugin, so we don't
// check it here.
});
it('should return 403 if the csrf_token is invalid', async () => {
const response = await fastify.inject({
method: 'GET',
url: '/',
cookies: {
_csrf: 'foo',
csrf_token: 'bar'
}
});
expect(response.statusCode).toEqual(403);
});
it('should allow the request if the csrf_token is valid', async () => {
const csrfResponse = await fastify.inject({
method: 'GET',
url: '/'
});
const csrfTokenCookie = csrfResponse.cookies.find(
cookie => cookie.name === CSRF_COOKIE
);
const csrfSecretCookie = csrfResponse.cookies.find(
cookie => cookie.name === CSRF_SECRET_COOKIE
);
const res = await fastify.inject({
method: 'GET',
url: '/',
cookies: {
_csrf: csrfSecretCookie!.value
},
headers: {
'csrf-token': csrfTokenCookie!.value
}
});
expect(res.json()).toEqual({ foo: 'bar' });
expect(res.statusCode).toEqual(200);
});
});

48
api/src/plugins/csrf.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { FastifyPluginCallback } from 'fastify';
import fastifyCsrfProtection from '@fastify/csrf-protection';
import fp from 'fastify-plugin';
export const CSRF_COOKIE = 'csrf_token';
export const CSRF_HEADER = 'csrf-token';
export const CSRF_SECRET_COOKIE = '_csrf';
/**
* Plugin for preventing CSRF attacks.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done Callback to signal that the logic has completed.
*/
const csrf: FastifyPluginCallback = (fastify, _options, done) => {
void fastify.register(fastifyCsrfProtection, {
// TODO: consider signing cookies. We don't on the api-server, but we could
// as an extra layer of security.
///Ignore all other possible sources of CSRF
// tokens since we know we can provide this one
getToken: req => req.headers[CSRF_HEADER] as string,
cookieOpts: { signed: false, sameSite: 'strict' }
});
// All routes except signout should add a CSRF token to the response
fastify.addHook('onRequest', (_req, reply, done) => {
const isSignout = _req.url === '/signout' || _req.url === '/signout/';
if (!isSignout) {
const token = reply.generateCsrf();
void reply.setCookie(CSRF_COOKIE, token, {
sameSite: 'strict',
signed: false,
// it needs to be read by the client, so that it can be sent in the
// header of the next request:
httpOnly: false
});
}
done();
});
done();
};
export default fp(csrf, { dependencies: ['cookies'] });