Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,14 @@ DEBUG_CACHE=false
# Enable debug logging for RPC calls
DEBUG_RPC=false

# Enable CORS for development
CORS_ENABLED=true
CORS_ORIGIN=http://localhost:3000
# ============================================
# CORS Configuration
# ============================================
# Comma-separated list of allowed origins.
# In production, set this explicitly to your deployed frontend URL(s).
CORS_ORIGIN=http://localhost:3000,http://localhost:5173,http://localhost:8080
# Optional extra dev origins; used when CORS_ORIGIN is unset (non-production only).
# CORS_ALLOWED_DEV_ORIGINS=http://localhost:3000,http://localhost:5173

# ============================================
# Webhook Delivery
Expand Down
26 changes: 13 additions & 13 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"dev": "tsx watch src/index.ts",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --coverage --runInBand --forceExit",
"test:migrations": "echo 'No migrations tests'",
"test:migrations": "bash scripts/test-migration-rollback.sh",
Comment on lines +13 to 14
"validate:curriculum": "tsx scripts/validate-curriculum.ts",

Expand Down Expand Up @@ -61,6 +62,7 @@
},
"devDependencies": {
"@as-integrations/express4": "^1.1.2",
"@types/autocannon": "^7.12.7",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
Expand All @@ -69,16 +71,15 @@
"@types/node": "^25.5.0",
"@types/qrcode": "^1.5.5",
"@types/sanitize-html": "^2.16.1",
"@types/autocannon": "^7.12.7",
"@types/supertest": "^7.2.0",
"@types/supertest": "^7.2.1",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "^8.18.1",
"autocannon": "^8.0.0",
"ioredis-mock": "^8.13.1",
"jest": "^30.4.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.11",
"ts-jest": "^29.4.12",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
Expand Down
78 changes: 78 additions & 0 deletions backend/src/config/cors.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import cors from 'cors';
import type { CorsOptions } from 'cors';
import config from './env.config.js';
import logger from '../utils/logger.js';

function parseOrigins(envValue: string | undefined): string[] {
if (!envValue) return [];
return envValue
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0);
}

function buildAllowedOrigins(): string[] {
const originEnv = process.env.CORS_ORIGIN || '';

if (originEnv.trim() !== '') {
return parseOrigins(originEnv);
}

if (config.app.env === 'production') {
return [];
}

if (config.app.env === 'development' || config.app.env === 'test') {
const devOrigins = parseOrigins(
process.env.CORS_ALLOWED_DEV_ORIGINS ||
'http://localhost:3000,http://localhost:5173,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:5173',
);
return devOrigins;
}

return [];
}

const allowedOrigins = buildAllowedOrigins();

export function createCorsMiddleware(): (req: any, res: any, next: any) => void {
const options: CorsOptions = {
origin: (origin, callback) => {
if (!origin) {
if (allowedOrigins.length > 0) {
return callback(null, false);
}
return callback(null, true);
}

if (allowedOrigins.includes(origin)) {
return callback(null, true);
}

logger.warn('CORS origin rejected', {
origin,
allowedOrigins,
env: config.app.env,
});

return callback(null, false);
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'],
maxAge: 86400,
preflightContinue: false,
optionsSuccessStatus: 204,
};

return cors(options);
}

export function getCorsConfigForLogging() {
return {
environment: config.app.env,
allowedOrigins,
hasWildcard: allowedOrigins.length === 0,
};
Comment on lines +73 to +77
}
4 changes: 3 additions & 1 deletion backend/src/graphql/server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express4';
import { json } from 'express';
import cors from 'cors';
import { json, type RequestHandler } from 'express';
Comment on lines +3 to 5
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
import { createGraphQLContext } from './context.js';
import { createCorsMiddleware } from '../config/cors.config.js';
import logger from '../utils/logger.js';

export const createGraphQLServer = async () => {
Expand Down Expand Up @@ -35,7 +37,7 @@ export const graphQLMiddleware = async (): Promise<RequestHandler[]> => {

return [
json(),
cors<cors.CorsRequest>({ origin: true }),
createCorsMiddleware(),
expressMiddleware(server, {
context: createGraphQLContext,
}) as RequestHandler,
Expand Down
5 changes: 4 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-nocheck
import cors from 'cors';
import express, { Request, Response } from 'express';
import { createServer } from 'http';
Expand All @@ -9,8 +10,10 @@ import distributedCacheManager from './cache/DistributedCacheManager.js';
import redisClient from './cache/RedisClient.js';
import { rpcCacheHeadersMiddleware, rpcCacheMiddleware } from './cache/RPCInterceptor.js';
import config from './config/env.config.js';
import { createCorsMiddleware } from './config/cors.config.js';
import { setRateLimitEnvOverrides } from './config/rateLimit.config.js';
import { swaggerSpec } from './config/swagger.js';
import type { CorsRequest } from 'cors';
import prisma from './db/index.js';
import { createGraphQLServer } from './graphql/server.js';
import { scheduleBackupCron, startBackupWorker, stopBackupWorker } from './jobs/backup.worker.js';
Expand Down Expand Up @@ -91,7 +94,7 @@ setRateLimitEnvOverrides({
},
});

app.use(cors());
app.use(createCorsMiddleware());
app.use(express.json());
app.use(securityHeadersMiddleware); // Add security headers early in middleware chain
app.use(decryptionMiddleware);
Expand Down
44 changes: 44 additions & 0 deletions backend/tests/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import request from 'supertest';
import { app } from '../src/index';

describe('CORS Middleware', () => {
describe('preflight requests', () => {
it('returns 204 for an allowed origin', async () => {
const response = await request(app)
.options('/health')
.set('Origin', 'http://localhost:3000')
.set('Access-Control-Request-Method', 'GET');

expect(response.status).toBe(204);
expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3000');
});

it('omits access-control headers for an unlisted origin', async () => {
const response = await request(app)
.options('/health')
.set('Origin', 'https://malicious.example')
.set('Access-Control-Request-Method', 'GET');

expect(response.headers['access-control-allow-origin']).toBeUndefined();
});
});

describe('simple cross-origin requests', () => {
it('sets CORS headers for allowed origins', async () => {
const response = await request(app)
.get('/health')
.set('Origin', 'http://localhost:3000');

expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3000');
expect(response.headers['access-control-allow-credentials']).toBe('true');
});

it('omits CORS headers for blocked origins', async () => {
const response = await request(app)
.get('/health')
.set('Origin', 'https://malicious.example');

expect(response.headers['access-control-allow-origin']).toBeUndefined();
});
});
});
Loading