From 46cdfd7802300eeba73856b374f301186ca75009 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Wed, 26 Apr 2023 09:02:12 +0200 Subject: [PATCH] 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 --- api/jest.config.ts | 3 +- api/jest.start-server.ts | 18 -------- api/jest.utils.ts | 30 +++++++++++++ api/src/app.ts | 3 ++ api/src/plugins/cors.ts | 41 ++++++++++++++++++ api/src/plugins/security.ts | 7 +-- api/src/server.test.ts | 86 ++++++++++++++++++++++++++++--------- api/src/utils/env.ts | 2 + 8 files changed, 147 insertions(+), 43 deletions(-) delete mode 100644 api/jest.start-server.ts create mode 100644 api/jest.utils.ts create mode 100644 api/src/plugins/cors.ts diff --git a/api/jest.config.ts b/api/jest.config.ts index bf1941a3a2b..60394011495 100644 --- a/api/jest.config.ts +++ b/api/jest.config.ts @@ -5,8 +5,7 @@ const config: Config = { testRegex: '\\.test\\.ts$', transform: { '^.+\\.ts$': 'ts-jest' - }, - setupFilesAfterEnv: ['/jest.start-server.ts'] + } }; export default config; diff --git a/api/jest.start-server.ts b/api/jest.start-server.ts deleted file mode 100644 index 7e2a58238d7..00000000000 --- a/api/jest.start-server.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { build } from './src/app'; - -declare global { - // eslint-disable-next-line no-var - var fastifyTestInstance: Awaited>; -} - -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(); -}); diff --git a/api/jest.utils.ts b/api/jest.utils.ts new file mode 100644 index 00000000000..d50d12104d8 --- /dev/null +++ b/api/jest.utils.ts @@ -0,0 +1,30 @@ +import request from 'supertest'; + +import { build } from './src/app'; + +declare global { + // eslint-disable-next-line no-var + var fastifyTestInstance: Awaited> | 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> | 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(); + }); +} diff --git a/api/src/app.ts b/api/src/app.ts index 21de2743ace..697456ec73e 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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 diff --git a/api/src/plugins/cors.ts b/api/src/plugins/cors.ts new file mode 100644 index 00000000000..6609acdffac --- /dev/null +++ b/api/src/plugins/cors.ts @@ -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); diff --git a/api/src/plugins/security.ts b/api/src/plugins/security.ts index 4438e5d6a73..3542f39387d 100644 --- a/api/src/plugins/security.ts +++ b/api/src/plugins/security.ts @@ -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); diff --git a/api/src/server.test.ts b/api/src/server.test.ts index 2cac64bd721..7d1414c670c 100644 --- a/api/src/server.test.ts +++ b/api/src/server.test.ts @@ -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' + }); }); }); }); diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index 7e488d60d59..e74938fd224 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -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';