feat(cors): add path-specific CORS configuration support
All checks were successful
armco-org/node-starter-kit/pipeline/head This commit looks good

- Add pathOverrides option for per-path CORS settings
- Add allowNullOrigin option for OAuth/OIDC flows
- Support whitelist merging or replacement per path
- Add comprehensive documentation with OAuth use case example
This commit is contained in:
2025-12-23 20:24:43 +05:30
parent fcd0aa10c3
commit ffeb50426e
4 changed files with 237 additions and 44 deletions

View File

@@ -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",

View File

@@ -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:

View File

@@ -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<string, CorsPathOverride>
[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

View File

@@ -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 || {}),