mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-24 11:03:17 -04:00
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:
committed by
GitHub
parent
293fb65063
commit
46cdfd7802
@@ -5,8 +5,7 @@ const config: Config = {
|
||||
testRegex: '\\.test\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest'
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.start-server.ts']
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -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
30
api/jest.utils.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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
41
api/src/plugins/cors.ts
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user