feat(api): add CORS headers (#50120)

* test: allow mocking of env vars

Since utils/env is a module, we can mock it to control env vars in
tests. However, it's not compatible with building the server in
setupFilesAfterEnv, so, instead, we can use a utility function to keep
things DRY.

* fix: update type of fastifyTestInstance

* chore: add comment about sts preload

* chore: rename header plugin

* test: add get util + provide origin on request

* feat: add cors headers

* chore: add TODO
This commit is contained in:
Oliver Eyton-Williams
2023-04-26 09:02:12 +02:00
committed by GitHub
parent 293fb65063
commit 46cdfd7802
8 changed files with 147 additions and 43 deletions

View File

@@ -5,8 +5,7 @@ const config: Config = {
testRegex: '\\.test\\.ts$',
transform: {
'^.+\\.ts$': 'ts-jest'
},
setupFilesAfterEnv: ['<rootDir>/jest.start-server.ts']
}
};
export default config;

View File

@@ -1,18 +0,0 @@
import { build } from './src/app';
declare global {
// eslint-disable-next-line no-var
var fastifyTestInstance: Awaited<ReturnType<typeof build>>;
}
beforeAll(async () => {
const fastify = await build();
await fastify.ready();
global.fastifyTestInstance = fastify;
});
afterAll(async () => {
// Due to a prisma bug, this is not enough, we need to --force-exit jest:
// https://github.com/prisma/prisma/issues/18146
await fastifyTestInstance?.close();
});

30
api/jest.utils.ts Normal file
View File

@@ -0,0 +1,30 @@
import request from 'supertest';
import { build } from './src/app';
declare global {
// eslint-disable-next-line no-var
var fastifyTestInstance: Awaited<ReturnType<typeof build>> | undefined;
}
export function superGet(endpoint: string): request.Test {
return request(fastifyTestInstance?.server)
.get(endpoint)
.set('Origin', 'https://www.freecodecamp.org');
}
export function setupServer(): void {
let fastify: Awaited<ReturnType<typeof build>> | undefined;
beforeAll(async () => {
fastify = await build();
await fastify.ready();
global.fastifyTestInstance = fastify;
});
afterAll(async () => {
// Due to a prisma bug, this is not enough, we need to --force-exit jest:
// https://github.com/prisma/prisma/issues/18146
await fastifyTestInstance?.close();
});
}

View File

@@ -16,6 +16,7 @@ import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUI from '@fastify/swagger-ui';
import fastifySentry from './plugins/fastify-sentry';
import cors from './plugins/cors';
import jwtAuthz from './plugins/fastify-jwt-authz';
import security from './plugins/security';
import sessionAuth from './plugins/session-auth';
@@ -63,6 +64,8 @@ export const build = async (
if (SENTRY_DSN) {
await fastify.register(fastifySentry, { dsn: SENTRY_DSN });
}
await fastify.register(cors);
await fastify.register(fastifyCookie);
// @ts-expect-error - @fastify/session's types are not, yet, compatible with
// express-session's types

41
api/src/plugins/cors.ts Normal file
View File

@@ -0,0 +1,41 @@
import { FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import { HOME_LOCATION } from '../utils/env';
// import { FREECODECAMP_NODE_ENV } from '../utils/env';
const allowedOrigins = [
'https://www.freecodecamp.dev',
'https://www.freecodecamp.org',
'https://beta.freecodecamp.dev',
'https://beta.freecodecamp.org',
'https://chinese.freecodecamp.dev',
'https://chinese.freecodecamp.org'
];
const cors: FastifyPluginCallback = (fastify, _options, done) => {
fastify.addHook('onRequest', async (req, reply) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
void reply.header('Access-Control-Allow-Origin', origin);
} else {
// TODO: Discuss if this is the correct approach. Standard practice is to
// reflect one of a list of allowed origins and handle development
// separately. If we switch to that approach we can replace use
// @fastify/cors instead.
void reply.header('Access-Control-Allow-Origin', HOME_LOCATION);
}
void reply
.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
)
.header('Access-Control-Allow-Credentials', true);
});
done();
};
export default fp(cors);

View File

@@ -4,7 +4,7 @@ import fp from 'fastify-plugin';
import { FREECODECAMP_NODE_ENV } from '../utils/env';
const fastifySentry: FastifyPluginCallback = (fastify, _options, done) => {
const securityHeaders: FastifyPluginCallback = (fastify, _options, done) => {
// OWASP recommended headers
fastify.addHook('onRequest', async (_request, reply) => {
void reply
@@ -13,7 +13,8 @@ const fastifySentry: FastifyPluginCallback = (fastify, _options, done) => {
.header('Content-Type', 'application/json; charset=utf-8')
.header('X-Content-Type-Options', 'nosniff')
.header('X-Frame-Options', 'DENY');
// TODO: Increase this gradually to 2 years.
// TODO: Increase this gradually to 2 years. Include preload once it is
// at least 1 year.
if (FREECODECAMP_NODE_ENV === 'production') {
void reply.header(
'Strict-Transport-Security',
@@ -25,4 +26,4 @@ const fastifySentry: FastifyPluginCallback = (fastify, _options, done) => {
done();
};
export default fp(fastifySentry);
export default fp(securityHeaders);

View File

@@ -1,26 +1,72 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import request from 'supertest';
import { setupServer, superGet } from '../jest.utils';
import { HOME_LOCATION } from './utils/env';
describe('GET /', () => {
test('have a 200 response', async () => {
const res = await request(fastifyTestInstance?.server).get('/');
expect(res?.statusCode).toBe(200);
});
jest.mock('./utils/env', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...jest.requireActual('./utils/env'),
// eslint-disable-next-line @typescript-eslint/naming-convention
FREECODECAMP_NODE_ENV: 'production'
};
});
test('return { "hello": "world"}', async () => {
const res = await request(fastifyTestInstance?.server).get('/');
expect(res?.body).toEqual({ hello: 'world' });
});
describe('production', () => {
describe('GET /', () => {
setupServer();
test('should have OWASP recommended headers', async () => {
const res = await request(fastifyTestInstance?.server).get('/');
// We also set Strict-Transport-Security, but only in production.
expect(res?.headers).toMatchObject({
'cache-control': 'no-store',
'content-security-policy': "frame-ancestors 'none'",
'content-type': 'application/json; charset=utf-8',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'
test('have a 200 response', async () => {
const res = await superGet('/');
expect(res.statusCode).toBe(200);
});
test('return { "hello": "world"}', async () => {
const res = await superGet('/');
expect(res.body).toEqual({ hello: 'world' });
});
test('should have OWASP recommended headers', async () => {
const res = await superGet('/');
expect(res.headers).toMatchObject({
'cache-control': 'no-store',
'content-security-policy': "frame-ancestors 'none'",
'content-type': 'application/json; charset=utf-8',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
'strict-transport-security': 'max-age=300; includeSubDomains'
});
});
test.each([
'https://www.freecodecamp.org',
'https://www.freecodecamp.dev',
'https://beta.freecodecamp.org',
'https://beta.freecodecamp.dev',
'https://chinese.freecodecamp.org',
'https://chinese.freecodecamp.dev'
])(
'should have Access-Control-Allow-Origin header for %s',
async origin => {
const res = await superGet('/').set('origin', origin);
expect(res.headers).toMatchObject({
'access-control-allow-origin': origin
});
}
);
test('should have HOME_LOCATION Access-Control-Allow-Origin header for other origins', async () => {
const res = await superGet('/').set('origin', 'https://www.google.com');
expect(res.headers).toMatchObject({
'access-control-allow-origin': HOME_LOCATION
});
});
test('should have Access-Control-Allow-(Headers+Credentials) headers', async () => {
const res = await superGet('/');
expect(res.headers).toMatchObject({
'access-control-allow-headers':
'Origin, X-Requested-With, Content-Type, Accept',
'access-control-allow-credentials': 'true'
});
});
});
});

View File

@@ -22,6 +22,7 @@ function isAllowedEnv(env: string): env is 'development' | 'production' {
return ['development', 'production'].includes(env);
}
assert.ok(process.env.HOME_LOCATION);
assert.ok(process.env.FREECODECAMP_NODE_ENV);
assert.ok(isAllowedEnv(process.env.FREECODECAMP_NODE_ENV));
assert.ok(process.env.AUTH0_DOMAIN);
@@ -51,6 +52,7 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
);
}
export const HOME_LOCATION = process.env.HOME_LOCATION;
export const MONGOHQ_URL =
process.env.MONGOHQ_URL ??
'mongodb://localhost:27017/freecodecamp?directConnection=true';