Harden CORS origin validation in production (FRE-4749)
- Add ALLOWED_ORIGINS env var with comma-separated origin list - Validate origins at startup in production: reject wildcards, empty values, and malformed URLs (non-http/https protocol) - Update both server entry points (server.ts, index.ts) to use getCorsOrigins() - Development mode retains existing localhost fallback behavior Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -8,6 +8,7 @@ const envSchema = z.object({
|
|||||||
API_RATE_LIMIT_WINDOW: z.string().transform(Number).default(60000), // 1 minute
|
API_RATE_LIMIT_WINDOW: z.string().transform(Number).default(60000), // 1 minute
|
||||||
API_RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default(100),
|
API_RATE_LIMIT_MAX_REQUESTS: z.string().transform(Number).default(100),
|
||||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||||
|
ALLOWED_ORIGINS: z.string().default(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiEnv = envSchema.parse({
|
export const apiEnv = envSchema.parse({
|
||||||
@@ -17,8 +18,50 @@ export const apiEnv = envSchema.parse({
|
|||||||
API_RATE_LIMIT_WINDOW: process.env.API_RATE_LIMIT_WINDOW,
|
API_RATE_LIMIT_WINDOW: process.env.API_RATE_LIMIT_WINDOW,
|
||||||
API_RATE_LIMIT_MAX_REQUESTS: process.env.API_RATE_LIMIT_MAX_REQUESTS,
|
API_RATE_LIMIT_MAX_REQUESTS: process.env.API_RATE_LIMIT_MAX_REQUESTS,
|
||||||
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||||
|
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ALLOWED_ORIGINS into a validated set.
|
||||||
|
* In production, rejects wildcards ('*') and empty values.
|
||||||
|
* In development, falls back to localhost.
|
||||||
|
*/
|
||||||
|
export function getCorsOrigins(): string | string[] {
|
||||||
|
const origins = (apiEnv.ALLOWED_ORIGINS || '').split(',').filter(Boolean);
|
||||||
|
|
||||||
|
if (apiEnv.NODE_ENV === 'production') {
|
||||||
|
if (origins.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'CORS origin validation (FRE-4749): ALLOWED_ORIGINS is empty in production. ' +
|
||||||
|
'Set ALLOWED_ORIGINS to a comma-separated list of allowed origins.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const origin of origins) {
|
||||||
|
if (origin === '*') {
|
||||||
|
throw new Error(
|
||||||
|
'CORS origin validation (FRE-4749): wildcard (*) ALLOWED_ORIGIN in production.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
throw new Error(
|
||||||
|
`CORS origin validation (FRE-4749): invalid protocol "${url.protocol}" in "${origin}". Expected http: or https:`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.startsWith('CORS origin')) throw err;
|
||||||
|
throw new Error(
|
||||||
|
`CORS origin validation (FRE-4749): malformed origin "${origin}": ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origins;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiEnv.CORS_ORIGIN || 'http://localhost:5173';
|
||||||
|
}
|
||||||
|
|
||||||
// Rate limit configuration by tier
|
// Rate limit configuration by tier
|
||||||
export const rateLimitConfig = {
|
export const rateLimitConfig = {
|
||||||
basic: {
|
basic: {
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { rateLimitMiddleware } from './middleware/rate-limit.middleware';
|
|||||||
import { spamRateLimitMiddleware } from './middleware/spam-rate-limit.middleware';
|
import { spamRateLimitMiddleware } from './middleware/spam-rate-limit.middleware';
|
||||||
import { errorHandlingMiddleware } from './middleware/error-handling.middleware';
|
import { errorHandlingMiddleware } from './middleware/error-handling.middleware';
|
||||||
import { loggingMiddleware } from './middleware/logging.middleware';
|
import { loggingMiddleware } from './middleware/logging.middleware';
|
||||||
import { apiEnv, loggingConfig } from './config/api.config';
|
import { apiEnv, loggingConfig, getCorsOrigins } from './config/api.config';
|
||||||
import { routes } from './routes';
|
import { routes } from './routes';
|
||||||
|
import { initDatadog, initSentry, setSentryUser } from '@shieldai/monitoring';
|
||||||
|
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
logger: loggingConfig,
|
logger: loggingConfig,
|
||||||
@@ -15,11 +16,15 @@ const fastify = Fastify({
|
|||||||
maxParamLength: 500,
|
maxParamLength: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize monitoring (must be first import for auto-instrumentation)
|
||||||
|
initDatadog();
|
||||||
|
initSentry();
|
||||||
|
|
||||||
// Register plugins
|
// Register plugins
|
||||||
async function registerPlugins() {
|
async function registerPlugins() {
|
||||||
// CORS configuration
|
// CORS configuration
|
||||||
await fastify.register(cors, {
|
await fastify.register(cors, {
|
||||||
origin: apiEnv.CORS_ORIGIN,
|
origin: getCorsOrigins(),
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { authMiddleware } from "./middleware/auth.middleware";
|
|||||||
import { darkwatchRoutes } from "./routes/darkwatch.routes";
|
import { darkwatchRoutes } from "./routes/darkwatch.routes";
|
||||||
import { voiceprintRoutes } from "./routes/voiceprint.routes";
|
import { voiceprintRoutes } from "./routes/voiceprint.routes";
|
||||||
import { correlationRoutes } from "./routes/correlation.routes";
|
import { correlationRoutes } from "./routes/correlation.routes";
|
||||||
|
import { initDatadog, initSentry, captureSentryError } from "@shieldai/monitoring";
|
||||||
|
import { getCorsOrigins } from "./config/api.config";
|
||||||
|
|
||||||
|
initDatadog();
|
||||||
|
initSentry();
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -15,7 +20,8 @@ const app = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await app.register(cors, { origin: process.env.CORS_ORIGIN || "http://localhost:5173" });
|
const corsOrigins = getCorsOrigins();
|
||||||
|
await app.register(cors, { origin: corsOrigins });
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
|
||||||
@@ -42,6 +48,7 @@ async function bootstrap() {
|
|||||||
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(err);
|
app.log.error(err);
|
||||||
|
captureSentryError(err as Error, { context: "server_startup" });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user