diff --git a/package.json b/package.json index f3672db..bc85d98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@armco/node-starter-kit", - "version": "2.0.6", + "version": "2.0.7", "description": "Modern plugin-based starter kit for Node.js applications with TypeScript, security, and observability", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/v2/middlewares/README.md b/v2/middlewares/README.md index 71759c5..bdaadaf 100644 --- a/v2/middlewares/README.md +++ b/v2/middlewares/README.md @@ -146,6 +146,77 @@ middlewareRegistry.initializeFromConfig(app, config.middlewares, logger) --- +## CORS: Path-Specific Configuration + +The CORS middleware supports **path-specific overrides** for fine-grained control over cross-origin policies. This is essential for OAuth/OIDC flows where browsers send `Origin: null` after cross-origin redirects. + +### Why Path-Specific CORS? + +In OAuth/OIDC flows: +1. User at `http://app.com` clicks "Login" +2. Redirected to `http://auth.com/authorize` +3. Browser treats the auth page as having an "opaque origin" due to cross-origin redirect +4. Form submission sends `Origin: null` + +Globally allowing `null` origin is a security risk. Instead, allow it **only for specific paths**. + +### Configuration Example + +```json +{ + "middlewares": { + "cors": { + "enabled": true, + "arOptions": { + "whitelist": [ + "http://localhost:3000", + "https://app.example.com" + ], + "credentials": true, + "pathOverrides": { + "/authorize": { + "allowNullOrigin": true + }, + "/api/v1/public": { + "replaceWhitelist": true, + "whitelist": ["*"] + }, + "/api/v1/webhooks": { + "whitelist": ["https://webhook-service.com"], + "credentials": false + } + } + } + } + } +} +``` + +### Path Override Options + +| Option | Type | Description | +|--------|------|-------------| +| `allowNullOrigin` | `boolean` | Allow `Origin: null` for this path (OAuth/OIDC flows) | +| `whitelist` | `string[]` | Additional origins to allow (merged with global) | +| `replaceWhitelist` | `boolean` | Replace global whitelist instead of merging | +| `credentials` | `boolean` | Override credentials setting for this path | + +### Path Matching + +- Paths are matched by **prefix** (e.g., `/authorize` matches `/authorize/login`) +- Longer prefixes take precedence (most specific wins) +- No path match = global config applies + +### Security Considerations + +- **Never globally allow `null` origin** - use `pathOverrides` instead +- `allowNullOrigin: true` is safe for OAuth endpoints because: + - Form submissions are protected by CSRF tokens + - OIDC validates `client_id`, `redirect_uri`, and issues time-limited codes + - Sensitive APIs should NOT have `allowNullOrigin` + +--- + ## Logging The registry automatically wraps initialization with consistent logging: diff --git a/v2/middlewares/security/cors.ts b/v2/middlewares/security/cors.ts index b94500c..d065938 100644 --- a/v2/middlewares/security/cors.ts +++ b/v2/middlewares/security/cors.ts @@ -3,6 +3,21 @@ import { Application, RequestHandler } from 'express' import { Logger } from '../../types/Logger' import { registerMiddleware } from '../../core/MiddlewareFactory' +/** + * Path-specific CORS override configuration + * Allows different CORS settings for specific URL path patterns + */ +export interface CorsPathOverride { + /** Additional origins to allow for this path (merged with global whitelist) */ + whitelist?: string[] + /** Whether to allow 'null' origin for this path (useful for OAuth/OIDC flows) */ + allowNullOrigin?: boolean + /** Override credentials setting for this path */ + credentials?: boolean + /** Completely replace the global whitelist instead of merging */ + replaceWhitelist?: boolean +} + export interface CorsConfig { enabled?: boolean arOptions?: { @@ -10,6 +25,28 @@ export interface CorsConfig { supportedDomains?: string[] credentials?: boolean credentails?: boolean + /** + * Path-specific CORS overrides + * Keys are path prefixes (e.g., '/authorize', '/api/v1/public') + * Values are override configurations that modify or extend the global CORS settings + * + * @example + * ```json + * { + * "pathOverrides": { + * "/authorize": { + * "allowNullOrigin": true, + * "whitelist": ["https://trusted-app.com"] + * }, + * "/api/v1/public": { + * "replaceWhitelist": true, + * "whitelist": ["*"] + * } + * } + * } + * ``` + */ + pathOverrides?: Record [key: string]: unknown } libOptions?: CorsOptions @@ -63,68 +100,150 @@ export async function initCors(app: Application, config: CorsConfig, logger?: Lo const ar = config.arOptions const rawWhitelist = ar?.whitelist ?? config.allowedOrigins - const whitelist = rawWhitelist?.map(normalizeOrigin) + const globalWhitelist = rawWhitelist?.map(normalizeOrigin) ?? [] const supportedDomains = ar?.supportedDomains ?? deriveSupportedDomainsFromWhitelist(rawWhitelist) - const credentials = (ar?.credentials ?? ar?.credentails ?? config.credentials) ?? true + const globalCredentials = (ar?.credentials ?? ar?.credentails ?? config.credentials) ?? true + const pathOverrides = ar?.pathOverrides ?? {} + + /** + * Find matching path override for a given request path + * Matches the longest prefix first for specificity + */ + const findPathOverride = (requestPath: string): CorsPathOverride | null => { + const overrideKeys = Object.keys(pathOverrides) + if (overrideKeys.length === 0) return null + + // Sort by length descending to match most specific path first + const sortedPaths = overrideKeys.sort((a, b) => b.length - a.length) + + for (const pathPrefix of sortedPaths) { + if (requestPath.startsWith(pathPrefix)) { + return pathOverrides[pathPrefix] + } + } + return null + } + + /** + * Build effective whitelist for a path, considering overrides + */ + const getEffectiveWhitelist = (override: CorsPathOverride | null): string[] => { + if (!override) return globalWhitelist + + if (override.replaceWhitelist && override.whitelist) { + return override.whitelist.map(normalizeOrigin) + } + + // Merge override whitelist with global + const merged = [...globalWhitelist] + if (override.whitelist) { + for (const o of override.whitelist) { + const norm = normalizeOrigin(o) + if (!merged.includes(norm)) { + merged.push(norm) + } + } + } + return merged + } const corsOptions: CorsOptions = { ...(config.libOptions || {}), - credentials, + credentials: globalCredentials, maxAge: config.maxAge, + methods: config.allowedMethods, + allowedHeaders: config.allowedHeaders, + exposedHeaders: config.exposedHeaders, } + /** + * Check if an origin is allowed for a given request path + */ + const isOriginAllowed = (origin: string | undefined, requestPath: string): boolean => { + const override = findPathOverride(requestPath) + const effectiveWhitelist = getEffectiveWhitelist(override) + const allowNullOrigin = override?.allowNullOrigin ?? false + + if (!origin) { + // Allow requests with no origin (like mobile apps or curl) + return true + } + + // Handle 'null' origin (from cross-origin redirects, sandboxed iframes, etc.) + if (origin === 'null') { + if (allowNullOrigin || effectiveWhitelist.includes('null')) { + logger?.debug('[NSK][CORS] Allowed null origin for path', { path: requestPath, allowNullOrigin }) + return true + } + logger?.warn('[NSK][CORS] Blocked null origin', { + path: requestPath, + hint: 'Set allowNullOrigin: true in pathOverrides for OAuth/OIDC flows', + }) + return false + } + + const normalized = normalizeOrigin(origin) + const root = extractRootDomain(normalized) + const domainAllowed = root ? (supportedDomains || []).includes(root) : false + const originAllowed = effectiveWhitelist.includes(normalized) + + if (originAllowed || domainAllowed) { + logger?.debug('[NSK][CORS] Allowed origin', { origin: normalized, path: requestPath, root, originAllowed, domainAllowed }) + return true + } + + logger?.warn('[NSK][CORS] Blocked request from origin', { + origin: normalized, + path: requestPath, + root, + supportedDomains, + effectiveWhitelist, + }) + return false + } + // Handle origin configuration if (corsOptions.origin) { // use libOptions.origin + app.use(cors(corsOptions) as RequestHandler) } else if (config.origin) { corsOptions.origin = config.origin - } else if (whitelist && whitelist.length > 0) { - corsOptions.origin = (origin, callback) => { - if (!origin) { - // Allow requests with no origin (like mobile apps or curl) - callback(null, true) - return - } - - const normalized = normalizeOrigin(origin) - const root = extractRootDomain(normalized) - const domainAllowed = root ? (supportedDomains || []).includes(root) : false - const originAllowed = whitelist.includes(normalized) - - if (originAllowed || domainAllowed) { - logger?.debug('[NSK][CORS] Allowed origin', { origin: normalized, root, originAllowed, domainAllowed }) - callback(null, true) - } else { - logger?.warn('[NSK][CORS] Blocked request from origin', { - origin: normalized, - root, - supportedDomains, - whitelist, - }) - callback(new Error(`Origin ${origin} not allowed by CORS`)) + app.use(cors(corsOptions) as RequestHandler) + } else if (globalWhitelist.length > 0 || Object.keys(pathOverrides).length > 0) { + // Use a custom middleware wrapper to access request path for path-specific CORS + const corsMiddleware: RequestHandler = (req, res, next) => { + const requestPath = req.path || req.url?.split('?')[0] || '/' + const origin = req.headers.origin + + // Create path-specific cors options + const pathCorsOptions: CorsOptions = { + ...corsOptions, + origin: (reqOrigin, callback) => { + if (isOriginAllowed(reqOrigin, requestPath)) { + callback(null, true) + } else { + callback(new Error(`Origin ${reqOrigin} not allowed by CORS`)) + } + } } + + // Apply cors with path-aware options + cors(pathCorsOptions)(req, res, next) } - logger?.debug('[NSK][CORS] Allowed origins:', { origins: whitelist, supportedDomains }) + app.use(corsMiddleware) + + logger?.debug('[NSK][CORS] Configured with path-aware origin checking', { + globalWhitelist, + supportedDomains, + pathOverrides: Object.keys(pathOverrides), + }) } else { // Default: allow all origins corsOptions.origin = true logger?.warn('[NSK][CORS] Allowing all origins (not recommended for production)') + app.use(cors(corsOptions) as RequestHandler) } - - if (config.allowedMethods) { - corsOptions.methods = config.allowedMethods - } - - if (config.allowedHeaders) { - corsOptions.allowedHeaders = config.allowedHeaders - } - - if (config.exposedHeaders) { - corsOptions.exposedHeaders = config.exposedHeaders - } - - app.use(cors(corsOptions) as RequestHandler) } // Self-register this middleware diff --git a/v2/middlewares/security/csrf.ts b/v2/middlewares/security/csrf.ts index d8611f5..1ceaa6c 100644 --- a/v2/middlewares/security/csrf.ts +++ b/v2/middlewares/security/csrf.ts @@ -53,10 +53,13 @@ export function initCsrf(app: Application, config: CsrfConfig, logger?: Logger): const cookieName = lib.cookieName || config.cookieName || '_csrf' const headerName = lib.headerName || config.headerName || 'x-csrf-token' + // Environment-aware sameSite: 'lax' for dev (same-origin), 'none' for prod (cross-domain) + const defaultSameSite = process.env.NODE_ENV === 'production' ? 'none' : 'lax' + const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'strict' as const, + sameSite: defaultSameSite as 'strict' | 'lax' | 'none', maxAge: 3600000, // 1 hour path: '/', ...(lib.cookieOptions || {}),