From 359658163d7aa32244a12d4badf46f3e98f48747 Mon Sep 17 00:00:00 2001 From: nice-bills Date: Thu, 28 May 2026 19:05:46 +0000 Subject: [PATCH 1/2] fix(cors): reject wildcard origin with credentials --- .env.example | 4 ++ src/config/corsOrigins.ts | 63 ++++++++++++++++++++++++ src/config/env.ts | 5 +- src/middleware/cors.ts | 54 +++++++++++++++----- tests/cors.test.ts | 101 ++++++++++++++++++++++++++++++++++++++ tests/env.test.ts | 5 ++ 6 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 src/config/corsOrigins.ts create mode 100644 tests/cors.test.ts diff --git a/.env.example b/.env.example index e976b85..30831c5 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ MONGODB_URI="mongodb://localhost:27017/acbu_db" RABBITMQ_URL="amqp://guest:guest@localhost:5672" JWT_SECRET="dev-jwt-secret-change-me" +# CORS — comma-separated exact origins (no *; required in production). +# Unset in development defaults to http://localhost:3000 +# CORS_ORIGIN=http://localhost:3000,https://app.example.com + # B-058: PII field-level encryption key (AES-256-GCM). # Must be a 64-character hex string (32 bytes). Required in production. # Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" diff --git a/src/config/corsOrigins.ts b/src/config/corsOrigins.ts new file mode 100644 index 0000000..21362a5 --- /dev/null +++ b/src/config/corsOrigins.ts @@ -0,0 +1,63 @@ +/** Default browser origin for local development when CORS_ORIGIN is unset. */ +export const DEFAULT_DEV_CORS_ORIGIN = "http://localhost:3000"; + +/** + * Parse and validate CORS_ORIGIN for credentialed cross-origin requests. + * Wildcard (*) is rejected: browsers forbid ACAO:* with Allow-Credentials. + */ +export function parseCorsOrigins(raw: string | undefined, nodeEnv: string): string[] { + const trimmed = raw?.trim(); + + if (!trimmed) { + if (nodeEnv === "production") { + throw new Error( + "CORS_ORIGIN is required in production. Set a comma-separated list of exact origins " + + "(e.g. https://app.example.com). Wildcard * is not allowed with credentials.", + ); + } + return [DEFAULT_DEV_CORS_ORIGIN]; + } + + const origins = trimmed + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + + if (origins.length === 0) { + throw new Error( + "CORS_ORIGIN must list at least one origin when set (wildcard * is not allowed).", + ); + } + + if (origins.some((origin) => origin === "*")) { + throw new Error( + "CORS_ORIGIN must not contain wildcard (*). List explicit origins; " + + "credentialed responses require Access-Control-Allow-Origin to match the request origin.", + ); + } + + const normalized: string[] = []; + for (const origin of origins) { + let url: URL; + try { + url = new URL(origin); + } catch { + throw new Error( + `Invalid CORS origin "${origin}". Use full origins such as https://app.example.com`, + ); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error( + `Invalid CORS origin "${origin}". Only http:// and https:// origins are supported.`, + ); + } + if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error( + `Invalid CORS origin "${origin}". Provide scheme, host, and port only (no path or credentials).`, + ); + } + normalized.push(url.origin); + } + + return [...new Set(normalized)]; +} diff --git a/src/config/env.ts b/src/config/env.ts index d5ea93a..f23bb09 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,5 +1,6 @@ import dotenv from "dotenv"; import { z } from "zod"; +import { parseCorsOrigins } from "./corsOrigins"; dotenv.config(); @@ -422,6 +423,6 @@ export const config = { maxTokensPerRequest: env.OPENAI_MAX_TOKENS_PER_REQUEST, }, - // CORS - corsOrigin: process.env.CORS_ORIGIN?.split(",") || [], + // CORS — explicit origins only; wildcard * is rejected (incompatible with credentials) + corsOrigin: parseCorsOrigins(process.env.CORS_ORIGIN, env.NODE_ENV), }; diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index f9dbf5f..aec7a03 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -1,17 +1,47 @@ -import cors from "cors"; +import cors, { type CorsOptions } from "cors"; import { config } from "../config/env"; -export const corsMiddleware = cors({ - origin: (origin, callback) => { - if ( - !origin || config.corsOrigin.includes(origin) - ) { - callback(null, true); - } else { - callback(new Error("Not allowed by CORS")); - } - }, +/** + * Resolve the Access-Control-Allow-Origin value for a request. + * Returns the exact origin string when allowed, false when denied, + * or null when the request has no Origin (non-browser / same-origin tools). + */ +export function resolveCorsOrigin( + requestOrigin: string | undefined, + allowedOrigins: readonly string[], +): string | false | null { + if (!requestOrigin) { + return null; + } + if (allowedOrigins.includes(requestOrigin)) { + return requestOrigin; + } + return false; +} + +const corsOptionsBase: Omit = { credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"], -}); +}; + +/** Build CORS middleware for a fixed allowlist (used in tests and production). */ +export function createCorsMiddleware(allowedOrigins: readonly string[]) { + return cors({ + ...corsOptionsBase, + origin: (origin, callback) => { + const resolved = resolveCorsOrigin(origin, allowedOrigins); + if (resolved === null) { + callback(null, true); + return; + } + if (resolved === false) { + callback(new Error("Not allowed by CORS")); + return; + } + callback(null, resolved); + }, + }); +} + +export const corsMiddleware = createCorsMiddleware(config.corsOrigin); diff --git a/tests/cors.test.ts b/tests/cors.test.ts new file mode 100644 index 0000000..d731356 --- /dev/null +++ b/tests/cors.test.ts @@ -0,0 +1,101 @@ +import express from "express"; +import request from "supertest"; +import { parseCorsOrigins, DEFAULT_DEV_CORS_ORIGIN } from "../src/config/corsOrigins"; +import { createCorsMiddleware, resolveCorsOrigin } from "../src/middleware/cors"; + +describe("parseCorsOrigins", () => { + it("defaults to localhost in non-production when unset", () => { + expect(parseCorsOrigins(undefined, "development")).toEqual([DEFAULT_DEV_CORS_ORIGIN]); + expect(parseCorsOrigins(undefined, "test")).toEqual([DEFAULT_DEV_CORS_ORIGIN]); + }); + + it("requires explicit origins in production when unset", () => { + expect(() => parseCorsOrigins(undefined, "production")).toThrow( + /CORS_ORIGIN is required in production/, + ); + }); + + it("rejects wildcard *", () => { + expect(() => parseCorsOrigins("*", "development")).toThrow(/wildcard/i); + expect(() => parseCorsOrigins("https://a.com,*", "development")).toThrow(/wildcard/i); + }); + + it("parses, trims, and deduplicates comma-separated origins", () => { + expect( + parseCorsOrigins( + " https://app.example.com ,https://admin.example.com,https://app.example.com ", + "development", + ), + ).toEqual(["https://app.example.com", "https://admin.example.com"]); + }); + + it("normalizes valid origins to URL.origin form", () => { + expect(parseCorsOrigins("https://app.example.com:443", "development")).toEqual([ + "https://app.example.com", + ]); + }); + + it("rejects origins with paths", () => { + expect(() => parseCorsOrigins("https://app.example.com/dashboard", "development")).toThrow( + /scheme, host, and port only/i, + ); + }); +}); + +describe("resolveCorsOrigin", () => { + const allowed = ["http://localhost:3000", "https://app.example.com"]; + + it("returns null when Origin header is absent", () => { + expect(resolveCorsOrigin(undefined, allowed)).toBeNull(); + }); + + it("returns the request origin when it is on the allowlist", () => { + expect(resolveCorsOrigin("https://app.example.com", allowed)).toBe("https://app.example.com"); + }); + + it("returns false for origins not on the allowlist", () => { + expect(resolveCorsOrigin("https://evil.example.com", allowed)).toBe(false); + }); +}); + +describe("createCorsMiddleware", () => { + const allowedOrigin = "http://localhost:3000"; + + function buildApp(allowedOrigins: string[]) { + const app = express(); + app.use(createCorsMiddleware(allowedOrigins)); + app.get("/ping", (_req, res) => { + res.json({ ok: true }); + }); + return app; + } + + it("reflects an allowed Origin (never *) with credentials", async () => { + const res = await request(buildApp([allowedOrigin])) + .get("/ping") + .set("Origin", allowedOrigin) + .expect(200); + + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + expect(res.headers["access-control-allow-origin"]).not.toBe("*"); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + it("rejects disallowed cross-origin requests", async () => { + await request(buildApp([allowedOrigin])) + .get("/ping") + .set("Origin", "https://evil.example.com") + .expect(500); + }); + + it("handles preflight OPTIONS with reflected origin", async () => { + const res = await request(buildApp([allowedOrigin])) + .options("/ping") + .set("Origin", allowedOrigin) + .set("Access-Control-Request-Method", "GET") + .expect(204); + + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); +}); diff --git a/tests/env.test.ts b/tests/env.test.ts index f2e5756..f9501a5 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -46,4 +46,9 @@ describe("env validation", () => { process.env.LOG_LEVEL = "invalid_level"; expect(() => require("../src/config/env")).toThrow(/LOG_LEVEL/); }); + + it("throws when CORS_ORIGIN contains wildcard", () => { + process.env.CORS_ORIGIN = "*"; + expect(() => require("../src/config/env")).toThrow(/wildcard/i); + }); }); From 8d0212df447cda96d9c21aaa01d6d9e3d3a5122f Mon Sep 17 00:00:00 2001 From: nice-bills Date: Fri, 29 May 2026 00:22:43 +0000 Subject: [PATCH 2/2] address PR review: CORS env validation and tests --- src/config/corsOrigins.ts | 12 ++- src/config/env.ts | 153 ++++++++++---------------------------- src/middleware/cors.ts | 6 +- tests/cors.test.ts | 16 +++- tests/env.test.ts | 5 +- 5 files changed, 69 insertions(+), 123 deletions(-) diff --git a/src/config/corsOrigins.ts b/src/config/corsOrigins.ts index 21362a5..b4de375 100644 --- a/src/config/corsOrigins.ts +++ b/src/config/corsOrigins.ts @@ -6,10 +6,11 @@ export const DEFAULT_DEV_CORS_ORIGIN = "http://localhost:3000"; * Wildcard (*) is rejected: browsers forbid ACAO:* with Allow-Credentials. */ export function parseCorsOrigins(raw: string | undefined, nodeEnv: string): string[] { + const normalizedEnv = nodeEnv.trim().toLowerCase(); const trimmed = raw?.trim(); if (!trimmed) { - if (nodeEnv === "production") { + if (normalizedEnv === "production") { throw new Error( "CORS_ORIGIN is required in production. Set a comma-separated list of exact origins " + "(e.g. https://app.example.com). Wildcard * is not allowed with credentials.", @@ -51,9 +52,14 @@ export function parseCorsOrigins(raw: string | undefined, nodeEnv: string): stri `Invalid CORS origin "${origin}". Only http:// and https:// origins are supported.`, ); } - if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + if (url.username || url.password || url.search || url.hash) { throw new Error( - `Invalid CORS origin "${origin}". Provide scheme, host, and port only (no path or credentials).`, + `Invalid CORS origin "${origin}". Provide scheme, host, and port only (no credentials in URL).`, + ); + } + if (url.pathname !== "/" || origin.endsWith("/")) { + throw new Error( + `Invalid CORS origin "${origin}". Provide scheme, host, and port only (no path or trailing slash).`, ); } normalized.push(url.origin); diff --git a/src/config/env.ts b/src/config/env.ts index f23bb09..06cdd79 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -26,10 +26,7 @@ const envSchema = z.object({ SIGNIN_LOCKOUT_DURATION_MS: z.coerce.number().default(15 * 60 * 1000), PII_ENCRYPTION_KEY: z .string() - .length( - 64, - "PII_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes)", - ) + .length(64, "PII_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes)") .regex(/^[0-9a-fA-F]+$/, "PII_ENCRYPTION_KEY must be a hex string") .optional(), OPENAI_API_KEY: z.string().optional(), @@ -39,28 +36,20 @@ const envSchema = z.object({ .string() .trim() .toLowerCase() - .pipe( - z.enum(["error", "warn", "info", "http", "verbose", "debug", "silly"]), - ) + .pipe(z.enum(["error", "warn", "info", "http", "verbose", "debug", "silly"])) .default("info"), + CORS_ORIGIN: z.string().optional(), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { - const messages = parsed.error.issues - .map((i) => `${i.path.join(".")}: ${i.message}`) - .join("\n"); + const messages = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n"); throw new Error(`Invalid environment variables:\n${messages}`); } -if ( - parsed.data.NODE_ENV === "production" && - !parsed.data.PRISMA_ACCELERATE_URL -) { - throw new Error( - "Missing required environment variable: PRISMA_ACCELERATE_URL", - ); +if (parsed.data.NODE_ENV === "production" && !parsed.data.PRISMA_ACCELERATE_URL) { + throw new Error("Missing required environment variable: PRISMA_ACCELERATE_URL"); } const env = parsed.data; @@ -87,10 +76,7 @@ export const config = { signinLockoutDurationMs: env.SIGNIN_LOCKOUT_DURATION_MS, // Rate Limiting Fallback (during cache outages) - rateLimitFallbackMaxRequests: parseInt( - process.env.RATE_LIMIT_FALLBACK_MAX_REQUESTS || "20", - 10, - ), + rateLimitFallbackMaxRequests: parseInt(process.env.RATE_LIMIT_FALLBACK_MAX_REQUESTS || "20", 10), rateLimitCircuitBreakerThreshold: parseInt( process.env.RATE_LIMIT_CIRCUIT_BREAKER_THRESHOLD || "5", 10, @@ -110,8 +96,7 @@ export const config = { secretKey: process.env.FLUTTERWAVE_SECRET_KEY || "", encryptionKey: process.env.FLUTTERWAVE_ENCRYPTION_KEY || "", webhookSecret: process.env.FLUTTERWAVE_WEBHOOK_SECRET || "", - baseUrl: - process.env.FLUTTERWAVE_BASE_URL || "https://api.flutterwave.com/v3", + baseUrl: process.env.FLUTTERWAVE_BASE_URL || "https://api.flutterwave.com/v3", }, paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY || "", @@ -127,43 +112,28 @@ export const config = { ? "https://momodeveloper.mtn.com" : "https://sandbox.momodeveloper.mtn.com"), targetEnvironment: - (process.env.MTN_MOMO_TARGET_ENVIRONMENT as "sandbox" | "production") || - "sandbox", + (process.env.MTN_MOMO_TARGET_ENVIRONMENT as "sandbox" | "production") || "sandbox", }, s3: { region: process.env.AWS_REGION || process.env.S3_REGION || "us-east-1", bucket: process.env.S3_BUCKET || "", endpoint: process.env.S3_ENDPOINT || "", - accessKeyId: - process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID || "", - secretAccessKey: - process.env.AWS_SECRET_ACCESS_KEY || - process.env.S3_SECRET_ACCESS_KEY || - "", - uploadUrlTtlSeconds: parseInt( - process.env.S3_UPLOAD_URL_TTL_SECONDS || "900", - 10, - ), - downloadUrlTtlSeconds: parseInt( - process.env.S3_DOWNLOAD_URL_TTL_SECONDS || "300", - 10, - ), + accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID || "", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY || "", + uploadUrlTtlSeconds: parseInt(process.env.S3_UPLOAD_URL_TTL_SECONDS || "900", 10), + downloadUrlTtlSeconds: parseInt(process.env.S3_DOWNLOAD_URL_TTL_SECONDS || "300", 10), scanWebhookSecret: process.env.S3_SCAN_WEBHOOK_SECRET || "", }, bulkTransfer: { chunkSize: parseInt(process.env.BULK_TRANSFER_CHUNK_SIZE || "100", 10), - maxFileSizeBytes: parseInt( - process.env.BULK_TRANSFER_MAX_FILE_SIZE_BYTES || "10485760", - 10, - ), + maxFileSizeBytes: parseInt(process.env.BULK_TRANSFER_MAX_FILE_SIZE_BYTES || "10485760", 10), }, fintech: { currencyProviders: ((): Record => { const raw = process.env.FINTECH_CURRENCY_PROVIDERS; if (raw) { try { - if (raw.startsWith("{")) - return JSON.parse(raw) as Record; + if (raw.startsWith("{")) return JSON.parse(raw) as Record; return Object.fromEntries( raw.split(",").map((p) => { const [k, v] = p.split("=").map((s) => s.trim()); @@ -192,8 +162,7 @@ export const config = { // Stellar stellar: { network: process.env.STELLAR_NETWORK || "testnet", - horizonUrl: - process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org", + horizonUrl: process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org", /** Soroban RPC (simulate + send). Override if default host fails DNS (e.g. use SDF friendbot list / custom RPC). */ sorobanRpcUrl: ((): string => { const explicit = process.env.STELLAR_SOROBAN_RPC_URL?.trim(); @@ -212,14 +181,13 @@ export const config = { nativeAssetCode: ((): string => { const explicit = process.env.STELLAR_NATIVE_ASSET_CODE?.trim(); if (explicit) return explicit.toUpperCase(); - const bootstrapProfile = (process.env.TESTNET_CUSTODIAL_BOOTSTRAP || "") - .trim() - .toLowerCase(); + const bootstrapProfile = (process.env.TESTNET_CUSTODIAL_BOOTSTRAP || "").trim().toLowerCase(); return bootstrapProfile.includes("pi") ? "PI" : "XLM"; })(), /** Wallet activation strategy. Default keeps the current create-account path, but makes it explicit/configurable. */ - activationStrategy: (process.env.WALLET_ACTIVATION_STRATEGY || - "create_account_native") as "create_account_native" | "disabled", + activationStrategy: (process.env.WALLET_ACTIVATION_STRATEGY || "create_account_native") as + | "create_account_native" + | "disabled", /** Optional bootstrap profile from deployment docs/runbooks; used only for config alignment and diagnostics. */ bootstrapProfile: process.env.TESTNET_CUSTODIAL_BOOTSTRAP || "", /** Minimum network-native balance sent to user wallet for activation. */ @@ -248,23 +216,15 @@ export const config = { /** When true, fetches the current recommended base fee from Horizon before each transaction. Falls back to baseFeeStroops on failure. */ useDynamicFees: process.env.STELLAR_USE_DYNAMIC_FEES === "true", /** Maximum total fee per Soroban transaction in stroops (base + resource fees). Default 10M stroops (~50 XLM at base fee 100). */ - sorobanMaxFeeStroops: parseInt( - process.env.STELLAR_SOROBAN_MAX_FEE_STROOPS || "10000000", - 10, - ), + sorobanMaxFeeStroops: parseInt(process.env.STELLAR_SOROBAN_MAX_FEE_STROOPS || "10000000", 10), /** Minimum total fee per Soroban transaction in stroops to prevent underpricing. Default 5000 stroops. */ - sorobanMinFeeStroops: parseInt( - process.env.STELLAR_SOROBAN_MIN_FEE_STROOPS || "5000", - 10, - ), + sorobanMinFeeStroops: parseInt(process.env.STELLAR_SOROBAN_MIN_FEE_STROOPS || "5000", 10), /** Circle USDC issuer on Stellar testnet. Default is the well-known Circle testnet issuer. */ usdcIssuerTestnet: - process.env.USDC_ISSUER_TESTNET ?? - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + process.env.USDC_ISSUER_TESTNET ?? "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", /** Circle USDC issuer on Stellar mainnet. Default is the well-known Circle mainnet issuer. */ usdcIssuerMainnet: - process.env.USDC_ISSUER_MAINNET ?? - "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + process.env.USDC_ISSUER_MAINNET ?? "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", /** Stellar asset code for the USDC-like swap asset on testnet (4–12 alphanumeric). Default `USDC`. */ usdcAssetCodeTestnet: process.env.USDC_ASSET_CODE_TESTNET || "USDC", /** Stellar asset code for the USDC-like swap asset on mainnet. Default `USDC`. */ @@ -275,23 +235,12 @@ export const config = { // Oracle (40/40/20: central bank, fintech, forex) oracle: { - updateIntervalHours: parseInt( - process.env.ORACLE_UPDATE_INTERVAL_HOURS || "6", - 10, - ), - emergencyThreshold: parseFloat( - process.env.ORACLE_EMERGENCY_THRESHOLD || "0.05", - ), - maxDeviationPerUpdate: parseFloat( - process.env.ORACLE_MAX_DEVIATION_PER_UPDATE || "0.05", - ), - circuitBreakerThreshold: parseFloat( - process.env.ORACLE_CIRCUIT_BREAKER_THRESHOLD || "0.10", - ), + updateIntervalHours: parseInt(process.env.ORACLE_UPDATE_INTERVAL_HOURS || "6", 10), + emergencyThreshold: parseFloat(process.env.ORACLE_EMERGENCY_THRESHOLD || "0.05"), + maxDeviationPerUpdate: parseFloat(process.env.ORACLE_MAX_DEVIATION_PER_UPDATE || "0.05"), + circuitBreakerThreshold: parseFloat(process.env.ORACLE_CIRCUIT_BREAKER_THRESHOLD || "0.10"), forex: { - baseUrl: - process.env.EXCHANGERATE_API_BASE_URL || - "https://v6.exchangerate-api.com/v6", + baseUrl: process.env.EXCHANGERATE_API_BASE_URL || "https://v6.exchangerate-api.com/v6", apiKey: process.env.EXCHANGERATE_API_KEY || "", }, centralBankUrls: ((): Record => { @@ -316,14 +265,10 @@ export const config = { // Notifications (email / SMS) notification: { - emailProvider: (process.env.NOTIFICATION_EMAIL_PROVIDER || "log") as - | "sendgrid" - | "ses" - | "log", + emailProvider: (process.env.NOTIFICATION_EMAIL_PROVIDER || "log") as "sendgrid" | "ses" | "log", emailFrom: process.env.NOTIFICATION_FROM_EMAIL || "noreply@acbu.io", sendgridApiKey: process.env.SENDGRID_API_KEY || "", - sesRegion: - process.env.AWS_REGION || process.env.AWS_SES_REGION || "us-east-1", + sesRegion: process.env.AWS_REGION || process.env.AWS_SES_REGION || "us-east-1", sesAccessKeyId: process.env.AWS_ACCESS_KEY_ID || "", sesSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", smsProvider: (process.env.NOTIFICATION_SMS_PROVIDER || "log") as @@ -347,14 +292,8 @@ export const config = { // Limits limits: { retail: { - depositDailyUsd: parseInt( - process.env.LIMIT_RETAIL_DEPOSIT_DAILY_USD || "5000", - 10, - ), - depositMonthlyUsd: parseInt( - process.env.LIMIT_RETAIL_DEPOSIT_MONTHLY_USD || "50000", - 10, - ), + depositDailyUsd: parseInt(process.env.LIMIT_RETAIL_DEPOSIT_DAILY_USD || "5000", 10), + depositMonthlyUsd: parseInt(process.env.LIMIT_RETAIL_DEPOSIT_MONTHLY_USD || "50000", 10), withdrawalSingleCurrencyDailyUsd: parseInt( process.env.LIMIT_RETAIL_WITHDRAWAL_DAILY_USD || "10000", 10, @@ -365,14 +304,8 @@ export const config = { ), }, business: { - depositDailyUsd: parseInt( - process.env.LIMIT_BUSINESS_DEPOSIT_DAILY_USD || "50000", - 10, - ), - depositMonthlyUsd: parseInt( - process.env.LIMIT_BUSINESS_DEPOSIT_MONTHLY_USD || "500000", - 10, - ), + depositDailyUsd: parseInt(process.env.LIMIT_BUSINESS_DEPOSIT_DAILY_USD || "50000", 10), + depositMonthlyUsd: parseInt(process.env.LIMIT_BUSINESS_DEPOSIT_MONTHLY_USD || "500000", 10), withdrawalSingleCurrencyDailyUsd: parseInt( process.env.LIMIT_BUSINESS_WITHDRAWAL_DAILY_USD || "100000", 10, @@ -383,14 +316,8 @@ export const config = { ), }, government: { - depositDailyUsd: parseInt( - process.env.LIMIT_GOV_DEPOSIT_DAILY_USD || "500000", - 10, - ), - depositMonthlyUsd: parseInt( - process.env.LIMIT_GOV_DEPOSIT_MONTHLY_USD || "5000000", - 10, - ), + depositDailyUsd: parseInt(process.env.LIMIT_GOV_DEPOSIT_DAILY_USD || "500000", 10), + depositMonthlyUsd: parseInt(process.env.LIMIT_GOV_DEPOSIT_MONTHLY_USD || "5000000", 10), withdrawalSingleCurrencyDailyUsd: parseInt( process.env.LIMIT_GOV_WITHDRAWAL_DAILY_USD || "500000", 10, @@ -404,9 +331,7 @@ export const config = { reserveWeightThresholdPct: parseFloat( process.env.LIMIT_CIRCUIT_BREAKER_RESERVE_WEIGHT_PCT || "10", ), - minReserveRatio: parseFloat( - process.env.LIMIT_CIRCUIT_BREAKER_MIN_RATIO || "1.02", - ), + minReserveRatio: parseFloat(process.env.LIMIT_CIRCUIT_BREAKER_MIN_RATIO || "1.02"), }, }, @@ -424,5 +349,5 @@ export const config = { }, // CORS — explicit origins only; wildcard * is rejected (incompatible with credentials) - corsOrigin: parseCorsOrigins(process.env.CORS_ORIGIN, env.NODE_ENV), + corsOrigin: parseCorsOrigins(env.CORS_ORIGIN, env.NODE_ENV), }; diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index aec7a03..cf2f224 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -25,7 +25,10 @@ const corsOptionsBase: Omit = { allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"], }; -/** Build CORS middleware for a fixed allowlist (used in tests and production). */ +/** + * Build CORS middleware for a fixed allowlist. + * Reflects the request origin when allowed; never emits `*` (incompatible with credentials). + */ export function createCorsMiddleware(allowedOrigins: readonly string[]) { return cors({ ...corsOptionsBase, @@ -44,4 +47,5 @@ export function createCorsMiddleware(allowedOrigins: readonly string[]) { }); } +/** Production CORS middleware using origins from validated env config. */ export const corsMiddleware = createCorsMiddleware(config.corsOrigin); diff --git a/tests/cors.test.ts b/tests/cors.test.ts index d731356..da1785d 100644 --- a/tests/cors.test.ts +++ b/tests/cors.test.ts @@ -13,6 +13,9 @@ describe("parseCorsOrigins", () => { expect(() => parseCorsOrigins(undefined, "production")).toThrow( /CORS_ORIGIN is required in production/, ); + expect(() => parseCorsOrigins(undefined, "Production")).toThrow( + /CORS_ORIGIN is required in production/, + ); }); it("rejects wildcard *", () => { @@ -35,10 +38,13 @@ describe("parseCorsOrigins", () => { ]); }); - it("rejects origins with paths", () => { + it("rejects origins with paths or trailing slashes", () => { expect(() => parseCorsOrigins("https://app.example.com/dashboard", "development")).toThrow( /scheme, host, and port only/i, ); + expect(() => parseCorsOrigins("https://app.example.com/", "development")).toThrow( + /scheme, host, and port only/i, + ); }); }); @@ -82,10 +88,12 @@ describe("createCorsMiddleware", () => { }); it("rejects disallowed cross-origin requests", async () => { - await request(buildApp([allowedOrigin])) + const res = await request(buildApp([allowedOrigin])) .get("/ping") - .set("Origin", "https://evil.example.com") - .expect(500); + .set("Origin", "https://evil.example.com"); + + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + expect(res.headers["access-control-allow-credentials"]).toBeUndefined(); }); it("handles preflight OPTIONS with reflected origin", async () => { diff --git a/tests/env.test.ts b/tests/env.test.ts index f9501a5..da4afe4 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -49,6 +49,9 @@ describe("env validation", () => { it("throws when CORS_ORIGIN contains wildcard", () => { process.env.CORS_ORIGIN = "*"; - expect(() => require("../src/config/env")).toThrow(/wildcard/i); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("../src/config/env"); + }).toThrow(/wildcard/i); }); });