diff --git a/AGENTS.md b/AGENTS.md index 24b34ae..cc7c99c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ src/ │ ├── validation-middleware.ts # Middleware wrapping InputValidator │ ├── error-handler.ts # SecureErrorHandler (production/development modes) │ ├── error-handler-middleware.ts # Middleware with circuit breaker detection +│ ├── jwt-auth.ts # Hardened JWT middleware (replaces 0http-bun JWT) │ ├── jwt-key-rotation.ts # JWKS refresh, multi-secret key rotation │ ├── jwt-key-rotation-middleware.ts │ ├── security-headers.ts # HSTS, CSP, X-Frame-Options, etc. @@ -78,6 +79,18 @@ src/ ## Security Design Principles +### JWT Authentication (jwt-auth.ts) + +The gateway uses an internal hardened JWT middleware (not the 0http-bun re-export). +Key behaviors: + +- `exp` is required on all tokens. +- `audience`/`issuer` are supported as top-level `auth` options. +- Allowed algorithms are derived from the key type when omitted. +- PEM-like strings cannot be used as HMAC secrets (algorithm confusion prevention). +- HS256 secrets must be at least 32 bytes. +- `excludePaths` matching is boundary-aware. + ### Path Validation (input-validator.ts + utils.ts) **Two-pass validation** against double-encoding attacks: @@ -100,9 +113,10 @@ Floor exempts when ALL targets are already unhealthy (genuine outage, not a casc ### Rate Limiting (gateway.ts) -Uses gateway's `getClientIP()` as the rate limit key generator, NOT `X-Forwarded-For`. +Uses gateway's `getClientIP()` as the rate limit key generator, NOT raw `X-Forwarded-For`. `getClientIP()` consults `TrustedProxyValidator` when enabled, otherwise falls back to -secure header priority (`cf-connecting-ip` > `x-real-ip`). +secure header priority (`cf-connecting-ip` > `x-real-ip`). These proxy-specific headers are +only honored when the corresponding `trustCloudflare` / `trustXRealIP` flags are enabled. ### Error Handling @@ -160,6 +174,15 @@ Covers: raw `..`, encoded `../`, encoded `/`, encoded `\`, null byte, double-enc 7. **TypeScript strict mode.** Properties like `noUncheckedIndexedAccess` are on. Array accesses like `targets[0]` need explicit `!` assertions or length checks. +8. **HTTP→HTTPS redirect requires a hostname or allowlist.** The redirect server now rejects + requests when neither `server.hostname` nor `tls.redirectAllowedHosts` is configured. + +9. **HS256 JWT secrets must be ≥ 32 bytes.** The hardened JWT middleware rejects short secrets + at startup to prevent brute-force forgery. + +10. **`listen(0)` picks a random free port.** `listen(port?)` falls back to `config.server.port` + and then `3000`; pass `0` to let Bun assign an ephemeral port. + ## CI/CD - Pushes to `main` trigger GitHub Actions (see `.github/workflows/`) diff --git a/README.md b/README.md index ba2f131..70f6b2d 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ For production deployments with HTTPS: import { BunGateway } from 'bungate' const gateway = new BunGateway({ - server: { port: 443 }, + server: { port: 443, hostname: 'localhost' }, security: { tls: { enabled: true, @@ -176,6 +176,8 @@ const gateway = new BunGateway({ minVersion: 'TLSv1.3', redirectHTTP: true, redirectPort: 80, + // Prevent open redirects via Host header + redirectAllowedHosts: ['localhost'], }, }, }) @@ -188,6 +190,7 @@ gateway.addRoute({ jwtOptions: { algorithms: ['HS256'], issuer: 'https://auth.example.com', + audience: 'https://api.example.com', }, }, }) @@ -305,6 +308,11 @@ const gateway = new BunGateway({ cluster: { enabled: true, workers: 4 }, auth: { secret: process.env.JWT_SECRET, + jwtOptions: { + algorithms: ['HS256'], + issuer: 'https://auth.myapp.com', + audience: 'https://api.myapp.com', + }, excludePaths: ['/health', '/auth/*'], }, cors: { diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 0733318..5b8229e 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -159,6 +159,8 @@ interface TLSConfig { cipherSuites?: string[] redirectHTTP?: boolean redirectPort?: number + /** Allowed Host header values for the HTTP->HTTPS redirect server. Supports leading wildcards. */ + redirectAllowedHosts?: string[] } ``` @@ -174,11 +176,28 @@ const gateway = new BunGateway({ minVersion: 'TLSv1.3', redirectHTTP: true, redirectPort: 80, + redirectAllowedHosts: ['example.com', '*.example.com'], }, }, }) ``` +#### TrustedProxiesConfig + +```typescript +interface TrustedProxiesConfig { + enabled: boolean + trustedIPs?: string[] + trustedNetworks?: string[] + maxForwardedDepth?: number + trustAll?: boolean + /** Honor CF-Connecting-IP only when the immediate proxy is Cloudflare. Default: false */ + trustCloudflare?: boolean + /** Honor X-Real-IP / X-Client-IP from the validated immediate proxy. Default: false */ + trustXRealIP?: boolean +} +``` + #### SecurityHeadersConfig ```typescript @@ -246,21 +265,45 @@ security: { ```typescript interface AuthConfig { - secret?: string + /** Symmetric secret, PEM string, imported key, or a resolver function */ + secret?: + | string + | Uint8Array + | JWTKeyLike + | ((req: Request) => JWTKeyLike | Promise) jwksUri?: string - jwtOptions?: { - algorithms: string[] - issuer?: string - audience?: string - maxAge?: string | number - } - apiKeys?: string[] + jwks?: any + jwtOptions?: Record + /** Required issuer claim (recommended) */ + issuer?: string | string[] + /** Required audience claim (recommended) */ + audience?: string | string[] + /** Explicitly allowed algorithms. Derived from the key type when omitted. */ + algorithms?: string[] + apiKeys?: + | string[] + | (( + apiKey: string, + req: Request, + ) => boolean | object | Promise) apiKeyHeader?: string - apiKeyValidator?: (key: string, req: Request) => Promise | boolean + apiKeyValidator?: ( + key: string, + req: Request, + ) => boolean | object | Promise + /** Boundary-aware path exclusions. `/public/*` excludes `/public/foo` but not `/publicity/foo */ excludePaths?: string[] optional?: boolean - getToken?: (req: Request) => string | null - onError?: (error: Error, req: Request) => Response + getToken?: (req: Request) => string | undefined | Promise + tokenHeader?: string + tokenQuery?: string + unauthorizedResponse?: + | Response + | ((error: Error, req: Request) => Response | object) + onError?: ( + error: Error, + req: Request, + ) => Response | void | Promise } ``` @@ -269,12 +312,10 @@ interface AuthConfig { ```typescript const gateway = new BunGateway({ auth: { - secret: process.env.JWT_SECRET, - jwtOptions: { - algorithms: ['HS256'], - issuer: 'https://auth.example.com', - audience: 'https://api.example.com', - }, + secret: process.env.JWT_SECRET, // HS256 secrets should be >= 32 bytes + issuer: 'https://auth.example.com', + audience: 'https://api.example.com', + // algorithms is optional; Bungate derives it from the key type. excludePaths: ['/health', '/public/*'], }, }) @@ -341,7 +382,7 @@ interface RouteConfig { circuitBreaker?: CircuitBreakerConfig timeout?: number middlewares?: Middleware[] - proxy?: ProxyConfig + proxy?: GatewayProxyOptions hooks?: RouteHooks } ``` @@ -440,6 +481,14 @@ interface StickySessionConfig { secure?: boolean // Default: false httpOnly?: boolean // Default: true sameSite?: 'strict' | 'lax' | 'none' + /** Maximum in-memory sessions. Oldest entries are evicted when the cap is hit. */ + maxSessions?: number // Default: 10000 + /** + * How to handle unknown session cookies. + * 'ignore' treats them as cookie-less (prevents memory DoS). + * 'create' mints a new session for the unknown value. + */ + unknownCookiePolicy?: 'ignore' | 'create' // Default: 'ignore' } ``` @@ -469,6 +518,8 @@ interface RateLimitConfig { keyGenerator?: (req: Request) => string message?: string statusCode?: number + /** Boundary-aware path exclusions for rate limiting */ + excludePaths?: string[] } ``` @@ -528,14 +579,22 @@ gateway.addRoute({ }) ``` -### ProxyConfig +### GatewayProxyOptions ```typescript -interface ProxyConfig { +interface GatewayProxyOptions { timeout?: number headers?: Record string)> stripPath?: boolean preserveHostHeader?: boolean + /** Rewrite incoming paths before forwarding (regex -> replacement, or function) */ + pathRewrite?: Record | ((path: string) => string) + /** Allowed redirect target hostnames when followRedirects is enabled. Supports leading wildcards. */ + redirectAllowlist?: string[] + /** Allow redirects only to the same origin as the original upstream target. Default: true */ + redirectSameOrigin?: boolean + /** Per-request fetch RequestInit options (used internally for SSRF protection) */ + request?: RequestInit } ``` @@ -553,6 +612,8 @@ gateway.addRoute({ }, stripPath: false, preserveHostHeader: true, + // Redirects are followed manually only for same-origin or allow-listed hosts. + redirectSameOrigin: true, }, }) ``` diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index c9a4b08..ccf13b9 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -44,13 +44,12 @@ import { BunGateway } from 'bungate' const gateway = new BunGateway({ server: { port: 3000 }, auth: { + // HS256 secrets must be at least 32 bytes. Bungate derives the allowed + // algorithm from the key type when algorithms is omitted. secret: process.env.JWT_SECRET, - jwtOptions: { - algorithms: ['HS256', 'RS256'], - issuer: 'https://auth.myapp.com', - audience: 'https://api.myapp.com', - }, - // Paths that don't require authentication + issuer: 'https://auth.myapp.com', + audience: 'https://api.myapp.com', + // Paths that don't require authentication (boundary-aware matching) excludePaths: [ '/health', '/metrics', @@ -86,12 +85,13 @@ gateway.addRoute({ pattern: '/admin/*', target: 'http://admin-service:3000', auth: { - secret: process.env.ADMIN_JWT_SECRET, + // For RS256/ES256, `secret` may be a PEM public key or a CryptoKey. + secret: process.env.ADMIN_JWT_PUBLIC_KEY!, jwtOptions: { algorithms: ['RS256'], - issuer: 'https://auth.myapp.com', - audience: 'https://admin.myapp.com', }, + issuer: 'https://auth.myapp.com', + audience: 'https://admin.myapp.com', optional: false, // Authentication is required }, }) @@ -102,10 +102,8 @@ gateway.addRoute({ target: 'http://user-service:3000', auth: { secret: process.env.JWT_SECRET, - jwtOptions: { - algorithms: ['HS256'], - issuer: 'https://auth.myapp.com', - }, + issuer: 'https://auth.myapp.com', + audience: 'https://api.myapp.com', }, }) @@ -117,6 +115,14 @@ gateway.addRoute({ }) ``` +### JWT Security Notes + +- **Tokens must include an `exp` claim.** Bungate rejects tokens without an expiration time. +- **Pin `issuer` and `audience`** to prevent cross-service token replay, especially when using JWKS. +- **HS256 secrets must be at least 32 bytes.** Short secrets are rejected at startup. +- **Algorithm confusion is blocked.** PEM-like secrets cannot be used as HMAC keys; the allowed algorithm family is derived from the key type when `algorithms` is omitted. +- **`excludePaths` uses boundary-aware matching.** `/public/*` excludes `/public/foo` but not `/publicity/foo`. + ### Custom Token Extraction By default, JWT tokens are extracted from the `Authorization` header. You can customize this: diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 7dc8ddb..fcd7256 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -46,12 +46,13 @@ Enterprise-grade security features and best practices for production deployments **Contents:** - Threat model -- TLS/HTTPS configuration -- Input validation & sanitization +- TLS/HTTPS configuration (redirect host allowlisting, cipher/version mapping) +- Input validation & sanitization (boundary-aware exclusions, encoding validation) - Security headers -- Session management -- Trusted proxy configuration +- Session management (bounded sticky sessions) +- Trusted proxy configuration (Cloudflare/X-Real-IP gating) - Request size limits +- JWT authentication hardening (`exp` required, algorithm derivation, aud/iss support) - JWT key rotation - Security checklist diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 07b66ac..d574039 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -38,15 +38,16 @@ const gateway = new BunGateway({ key: './certs/key.pem', redirectHTTP: true, redirectPort: 80, + redirectAllowedHosts: ['api.myapp.com', '*.myapp.com'], }, }, auth: { secret: process.env.JWT_SECRET, jwtOptions: { algorithms: ['HS256'], - issuer: 'https://auth.myapp.com', - audience: 'https://api.myapp.com', }, + issuer: 'https://auth.myapp.com', + audience: 'https://api.myapp.com', excludePaths: ['/health', '/metrics', '/auth/*', '/public/*'], }, cors: { @@ -139,7 +140,9 @@ gateway.addRoute({ rateLimit: { max: 1000, windowMs: 60000, - keyGenerator: (req) => req.headers.get('x-forwarded-for') || 'unknown', + // Prefer a trusted client identifier. Avoid raw X-Forwarded-For unless + // trusted proxies are explicitly configured. + keyGenerator: (req) => req.headers.get('cf-connecting-ip') || 'unknown', }, }) @@ -234,6 +237,8 @@ gateway.addRoute({ ttl: 3600000, secure: true, httpOnly: true, + maxSessions: 10000, + unknownCookiePolicy: 'ignore', }, }, }) diff --git a/docs/LOAD_BALANCING.md b/docs/LOAD_BALANCING.md index ebbf5c6..c22e220 100644 --- a/docs/LOAD_BALANCING.md +++ b/docs/LOAD_BALANCING.md @@ -503,6 +503,10 @@ gateway.addRoute({ secure: true, // HTTPS only httpOnly: true, // No JavaScript access sameSite: 'lax', + // Cap in-memory sessions to prevent unbounded growth + maxSessions: 10000, + // Ignore unknown cookie values instead of minting a new session + unknownCookiePolicy: 'ignore', }, }, }) @@ -514,6 +518,8 @@ gateway.addRoute({ 2. Gateway sets a cookie identifying the chosen server 3. Subsequent requests with the cookie go to the same server 4. If server is unhealthy, fallback to base strategy +5. Unknown cookie values are ignored by default to prevent memory exhaustion +6. The session map is capped (`maxSessions`) with LRU eviction ## Advanced Configuration @@ -841,6 +847,8 @@ stickySession: { enabled: true, cookieName: 'session_id', ttl: 3600000, + maxSessions: 10000, + unknownCookiePolicy: 'ignore', } // 2. Use IP hash diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index a722929..0a4945b 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -160,8 +160,9 @@ gateway.addRoute({ max: 100, // 100 requests windowMs: 60000, // per minute keyGenerator: (req) => { - // Rate limit by IP address - return req.headers.get('x-forwarded-for') || 'unknown' + // Use a trusted client identifier. Avoid raw X-Forwarded-For unless + // trusted proxies are explicitly configured. + return req.headers.get('cf-connecting-ip') || 'unknown' }, }, }) @@ -178,8 +179,9 @@ const gateway = new BunGateway({ secret: process.env.JWT_SECRET, jwtOptions: { algorithms: ['HS256'], - issuer: 'https://auth.myapp.com', }, + issuer: 'https://auth.myapp.com', + audience: 'https://api.myapp.com', excludePaths: ['/health', '/auth/login'], }, }) @@ -199,7 +201,7 @@ Enable HTTPS with TLS: ```typescript const gateway = new BunGateway({ - server: { port: 443 }, + server: { port: 443, hostname: 'example.com' }, security: { tls: { enabled: true, @@ -208,6 +210,7 @@ const gateway = new BunGateway({ minVersion: 'TLSv1.3', redirectHTTP: true, redirectPort: 80, + redirectAllowedHosts: ['example.com', '*.example.com'], }, }, }) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a042626..ada7171 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -168,7 +168,7 @@ TLS (Transport Layer Security) encrypts all traffic between clients and the gate import { BunGateway } from 'bungate' const gateway = new BunGateway({ - server: { port: 443 }, + server: { port: 443, hostname: 'example.com' }, security: { tls: { enabled: true, @@ -177,6 +177,8 @@ const gateway = new BunGateway({ minVersion: 'TLSv1.3', redirectHTTP: true, redirectPort: 80, + // Prevent open redirects via Host header + redirectAllowedHosts: ['example.com', '*.example.com'], }, }, }) @@ -188,7 +190,7 @@ await gateway.listen() ```typescript const gateway = new BunGateway({ - server: { port: 443 }, + server: { port: 443, hostname: 'example.com' }, security: { tls: { enabled: true, @@ -205,6 +207,7 @@ const gateway = new BunGateway({ rejectUnauthorized: true, redirectHTTP: true, redirectPort: 80, + redirectAllowedHosts: ['example.com', '*.example.com'], }, }, }) @@ -299,6 +302,7 @@ gateway.addRoute({ ### Attack Prevention - **Directory Traversal**: `../` sequences blocked and sanitized +- **Double/Multiple Encoding**: Paths are recursively decoded; still-encoded sequences after decoding are rejected - **Null Byte Injection**: `%00` and null characters rejected - **XSS Attempts**: Script tags and JavaScript protocols blocked - **SQL Injection**: Special characters in query params validated @@ -357,20 +361,15 @@ gateway.addRoute({ } ``` -**Development Mode** (detailed): +**Development Mode** (sanitized by default; set `includeStackTrace: true` for detailed responses): ```json { "error": { "code": "INTERNAL_ERROR", - "message": "Database connection failed: ECONNREFUSED", + "message": "An internal error occurred", "requestId": "req_abc123", - "timestamp": 1699564800000, - "stack": "Error: Database connection failed...", - "details": { - "host": "localhost", - "port": 5432 - } + "timestamp": 1699564800000 } } ``` @@ -465,6 +464,10 @@ gateway.addRoute({ enabled: true, cookieName: 'app_session', ttl: 3600000, + // Cap in-memory sessions to prevent unbounded growth + maxSessions: 10000, + // Ignore unknown cookie values instead of minting new sessions + unknownCookiePolicy: 'ignore', }, }, }) @@ -546,11 +549,16 @@ const gateway = new BunGateway({ ], maxForwardedDepth: 3, trustAll: false, // Never use in production! + // Only enable these when the immediate upstream is actually that proxy type + trustCloudflare: true, + trustXRealIP: false, }, }, }) ``` +**Cloudflare / X-Real-IP headers:** These proxy-specific headers are ignored by default even when the peer is trusted. Only enable `trustCloudflare` when the immediate upstream is Cloudflare, and `trustXRealIP` when your trusted proxy is responsible for setting `X-Real-IP`. + ### Predefined Trusted Networks Bungate includes IP ranges for major CDN and cloud providers: diff --git a/docs/TLS_CONFIGURATION.md b/docs/TLS_CONFIGURATION.md index cdde81f..a66c67b 100644 --- a/docs/TLS_CONFIGURATION.md +++ b/docs/TLS_CONFIGURATION.md @@ -84,6 +84,7 @@ await gateway.listen() const gateway = new BunGateway({ server: { port: 443, // HTTPS port + hostname: 'example.com', }, security: { tls: { @@ -92,6 +93,7 @@ const gateway = new BunGateway({ key: './key.pem', redirectHTTP: true, redirectPort: 80, // HTTP port for redirects + redirectAllowedHosts: ['example.com', '*.example.com'], }, }, }) @@ -187,6 +189,9 @@ interface TLSConfig { /** HTTP port for redirects */ redirectPort?: number + + /** Allowed Host header values for the HTTP->HTTPS redirect server. Supports leading wildcards. */ + redirectAllowedHosts?: string[] } ``` @@ -295,7 +300,7 @@ When `redirectHTTP` is enabled, Bungate automatically starts a second server on ```typescript const gateway = new BunGateway({ - server: { port: 443 }, + server: { port: 443, hostname: 'example.com' }, security: { tls: { enabled: true, @@ -303,6 +308,7 @@ const gateway = new BunGateway({ key: './key.pem', redirectHTTP: true, redirectPort: 80, + redirectAllowedHosts: ['example.com', '*.example.com'], }, }, }) @@ -313,6 +319,7 @@ The redirect server: - Returns HTTP 301 (Moved Permanently) - Preserves the request path and query parameters - Sets the `Location` header to the HTTPS URL +- Validates the request `Host` against `redirectAllowedHosts` or `server.hostname` to prevent open redirects - Closes the connection after redirect ## Troubleshooting @@ -630,7 +637,7 @@ Default cipher suites prioritize security and performance: ### Minimum TLS Version -Set minimum TLS version to enforce security standards: +Set minimum TLS version to enforce security standards. Bungate maps `minVersion` and `cipherSuites` to the Bun-native `secureOptions` and `ciphers` fields so the hardening is actually honored: ```typescript tls: { @@ -666,6 +673,7 @@ const gateway = new BunGateway({ cipherSuites: ['TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256'], redirectHTTP: true, redirectPort: 80, + redirectAllowedHosts: ['example.com', '*.example.com'], }, // Input validation diff --git a/examples/security-hardened.ts b/examples/security-hardened.ts index 68c8960..4105bf9 100644 --- a/examples/security-hardened.ts +++ b/examples/security-hardened.ts @@ -118,7 +118,7 @@ const config = { tlsCert: process.env.TLS_CERT_PATH || './examples/cert.pem', tlsKey: process.env.TLS_KEY_PATH || './examples/key.pem', - // JWT Secrets (use strong, random keys in production) + // JWT Secrets (use strong, random keys with >= 32 bytes for HS256) jwtPrimary: requireEnv('JWT_SECRET_PRIMARY'), jwtOld: requireEnv('JWT_SECRET_OLD'), @@ -182,6 +182,9 @@ const gateway = new BunGateway({ ], redirectHTTP: true, redirectPort: config.httpPort, + // Prevent open redirect via Host header. Use an allowlist or set + // server.hostname explicitly. + redirectAllowedHosts: ['localhost'], }, // Input validation @@ -230,6 +233,10 @@ const gateway = new BunGateway({ trustedIPs: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'], trustedNetworks: ['cloudflare'], maxForwardedDepth: 2, + // Only enable these when the immediate upstream is actually Cloudflare + // or a trusted proxy that sets these headers. + trustCloudflare: false, + trustXRealIP: false, }, // Security headers @@ -461,6 +468,13 @@ const gateway = new BunGateway({ path: '/health', expectedStatus: 200, }, + stickySession: { + enabled: false, // Enable only if backends require affinity + cookieName: 'lb-session', + ttl: 3600000, + maxSessions: 10000, + unknownCookiePolicy: 'ignore', + }, }, circuitBreaker: { enabled: true, diff --git a/package.json b/package.json index 575f6d4..d4697ea 100644 --- a/package.json +++ b/package.json @@ -54,4 +54,4 @@ "pino-pretty": "^13.1.2", "prom-client": "^15.1.3" } -} \ No newline at end of file +} diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 18ff270..cee4c5c 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -43,22 +43,23 @@ import type { ZeroRequest, IRouter, IRouterConfig, + StepFunction, } from '../interfaces/middleware' // Import 0http-bun middlewares import { createLogger, - createJWTAuth, createCORS, createBodyParser, createPrometheusMiddleware, - type JWTAuthOptions, type CORSOptions, type PrometheusMiddlewareOptions, createRateLimit, } from '0http-bun/lib/middleware' // Import our custom implementations +import { createJWTAuth, matchesExcludedPath } from '../security' +import type { JWTAuthOptions } from '../interfaces/middleware' import { createGatewayProxy } from '../proxy/gateway-proxy' import { HttpLoadBalancer } from '../load-balancer/http-load-balancer' import type { ProxyInstance } from '../interfaces/proxy' @@ -152,38 +153,6 @@ export class BunGateway implements Gateway { ) } - // Create 0http-bun router with configuration - // Build a proper global error handler from user config (or use secure defaults) - const errorHandlerFn = - typeof config.errorHandler === 'function' - ? // User provided a custom error handler function — use it directly - config.errorHandler - : // Default: log the error, return safe 500 without leaking internals - (err: Error, req?: any) => { - const includeStack = - this.config.security?.errorHandling?.includeStackTrace === true || - this.config.server?.development === true - - this.config.logger?.error( - { - error: err.message, - ...(includeStack && err.stack ? { stack: err.stack } : {}), - url: req?.url, - method: req?.method, - clientIP: req ? this.getClientIP(req) : undefined, - }, - 'Unhandled gateway error', - ) - - return new Response( - JSON.stringify({ error: 'Internal server error' }), - { - status: 500, - headers: { 'content-type': 'application/json' }, - }, - ) - } - const routerConfig: IRouterConfig = { // Map gateway config to router config defaultRoute: config.defaultRoute @@ -194,33 +163,66 @@ export class BunGateway implements Gateway { status: 404, headers: { 'content-type': 'application/json' }, }), - errorHandler: errorHandlerFn, + errorHandler: this.handleError, port: config.server?.port, } const { router } = http(routerConfig) this.router = router - // Create logger middleware if configured + // Create logger middleware if configured. + // Wrap the response serializer so that session cookies are never written + // to the request log in plaintext (V-7). + const userSerializers = config.logger?.getSerializers?.() + const safeSerializers: Record = { ...userSerializers } + const originalResSerializer = safeSerializers.res + safeSerializers.res = (response: Response) => { + const serialized = originalResSerializer + ? originalResSerializer(response) + : { + statusCode: response.status, + headers: Object.fromEntries(response.headers.entries()), + } + if (serialized.headers) { + const headers = { ...serialized.headers } + delete headers['set-cookie'] + delete headers['cookie'] + serialized.headers = headers + } + return serialized + } + this.router.use( createLogger({ // @ts-ignore logger: config.logger?.pino, - serializers: config.logger?.getSerializers(), + serializers: safeSerializers, }), ) - // Add global rate limiting middleware if configured at the gateway level + // Add global rate limiting middleware if configured at the gateway level. + // excludePaths use boundary-aware matching so '/api/public' does not + // accidentally exempt '/api/publicity/admin' (V-6). if (this.config.rateLimit) { + const { excludePaths, ...rateLimitRest } = this.config.rateLimit const globalRateLimitKeyGenerator = (req: ZeroRequest) => this.getClientIP(req) - this.router.use( - createRateLimit({ - ...this.config.rateLimit, - keyGenerator: - this.config.rateLimit.keyGenerator || globalRateLimitKeyGenerator, - }), - ) + const rateLimitMiddleware = createRateLimit({ + ...rateLimitRest, + keyGenerator: rateLimitRest.keyGenerator || globalRateLimitKeyGenerator, + }) + + if (excludePaths && excludePaths.length > 0) { + this.router.use(async (req: ZeroRequest, next: StepFunction) => { + const url = new URL(req.url) + if (matchesExcludedPath(url.pathname, excludePaths)) { + return next() + } + return rateLimitMiddleware(req, next) + }) + } else { + this.router.use(rateLimitMiddleware) + } } // Add size limiter middleware with secure defaults (always enabled) @@ -258,7 +260,7 @@ export class BunGateway implements Gateway { const securityHeaders = new SecurityHeadersMiddleware(headersConfig) // Wrap the response to apply security headers - this.router.use(async (req: ZeroRequest, next) => { + this.router.use(async (req: ZeroRequest, next: StepFunction) => { const response = await next() const url = new URL(req.url) const isHttps = @@ -304,9 +306,51 @@ export class BunGateway implements Gateway { } } - fetch = (req: Request) => { - // 0http-bun expects a Request, returns a Response - return this.router.fetch(req) + fetch = async (req: Request): Promise => { + // 0http-bun expects a Request, returns a Response. + // Await the router result so async throws are caught by our sanitized + // error handler instead of leaking Bun's debug page (V-9). + try { + return await this.router.fetch(req) + } catch (err) { + return await this.handleError(err as Error, req as ZeroRequest) + } + } + + /** + * Secure global error handler. + * Logs full details internally and returns a sanitized 500 to the client. + */ + private handleError = async ( + err: Error, + req?: ZeroRequest, + ): Promise => { + if (typeof this.config.errorHandler === 'function') { + const result = await (this.config.errorHandler as any)(err, req) + if (result instanceof Response) { + return result + } + } + + const includeStack = + this.config.security?.errorHandling?.includeStackTrace === true || + this.config.server?.development === true + + this.config.logger?.error( + { + error: err.message, + ...(includeStack && err.stack ? { stack: err.stack } : {}), + url: req?.url, + method: req?.method, + clientIP: req ? this.getClientIP(req) : undefined, + }, + 'Unhandled gateway error', + ) + + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }) } use(...args: any[]): this { @@ -378,8 +422,9 @@ export class BunGateway implements Gateway { // Add authentication middleware if configured (before custom middleware) if (route.auth) { - // Pass through all authentication options to support both JWT and API key auth - // When jwtOptions is provided, merge root-level and nested options + // Pass through all authentication options to support both JWT and API key auth. + // The internal createJWTAuth derives allowed algorithms from the key type, + // requires an `exp` claim, and supports audience/issuer validation. const { jwtOptions: routeJwtOptions, ...authRest } = route.auth const jwtOptions = routeJwtOptions @@ -393,12 +438,6 @@ export class BunGateway implements Gateway { ...authRest, } - // Convert string secret to Uint8Array for HMAC algorithms (jose library requirement) - // RSA/ECDSA keys should be passed as CryptoKey or KeyLike objects - if (jwtOptions.secret && typeof jwtOptions.secret === 'string') { - jwtOptions.secret = new TextEncoder().encode(jwtOptions.secret) - } - middlewares.push(createJWTAuth(jwtOptions as JWTAuthOptions)) } @@ -410,12 +449,25 @@ export class BunGateway implements Gateway { const rateLimitKeyGenerator = (req: ZeroRequest) => { return this.getClientIP(req) } - middlewares.push( - createRateLimit({ - ...route.rateLimit, - keyGenerator: route.rateLimit.keyGenerator || rateLimitKeyGenerator, - }), - ) + const { excludePaths: routeExcludePaths, ...routeRateLimitRest } = + route.rateLimit + const routeRateLimitMiddleware = createRateLimit({ + ...routeRateLimitRest, + keyGenerator: + routeRateLimitRest.keyGenerator || rateLimitKeyGenerator, + }) + + if (routeExcludePaths && routeExcludePaths.length > 0) { + middlewares.push(async (req: ZeroRequest, next: StepFunction) => { + const url = new URL(req.url) + if (matchesExcludedPath(url.pathname, routeExcludePaths)) { + return next() + } + return routeRateLimitMiddleware(req, next) + }) + } else { + middlewares.push(routeRateLimitMiddleware) + } } // Add route-specific middlewares after security middleware @@ -512,6 +564,7 @@ export class BunGateway implements Gateway { // Measure end-to-end time to update latency metrics in the load balancer const startedAt = Date.now() loadBalancer.incrementConnections(target.url) + let connectionsDecremented = false response = await proxy.proxy(proxyReq as ZeroRequest, upstreamUrl, { afterCircuitBreakerExecution: route.hooks?.afterCircuitBreakerExecution, @@ -522,14 +575,27 @@ export class BunGateway implements Gateway { res: Response, body?: ReadableStream | null, ) => { + if (connectionsDecremented) return + connectionsDecremented = true loadBalancer.decrementConnections(target.url) // Update latency stats for strategies like 'latency' and as tie-breakers try { const duration = Date.now() - startedAt - loadBalancer.recordResponse(target.url, duration, false) + loadBalancer.recordResponse( + target.url, + duration, + res.status >= 500, + ) } catch {} }, onError: (req: Request, error: Error) => { + if (connectionsDecremented) { + if (route.hooks?.onError) { + route.hooks.onError!(req, error) + } + return + } + connectionsDecremented = true loadBalancer.decrementConnections(target.url) // Record error with latency to penalize target appropriately try { @@ -577,13 +643,14 @@ export class BunGateway implements Gateway { } } + const upstreamUrl = route.target + targetPath const proxyReq = this.sanitizeProxyRequest( req, - targetPath, + upstreamUrl, route.proxy, ) - response = await proxy.proxy(proxyReq as ZeroRequest, targetPath, { + response = await proxy.proxy(proxyReq as ZeroRequest, upstreamUrl, { afterCircuitBreakerExecution: route.hooks?.afterCircuitBreakerExecution, beforeCircuitBreakerExecution: @@ -808,7 +875,7 @@ export class BunGateway implements Gateway { } async listen(port?: number): Promise { - const listenPort = port || this.config.server?.port || 3000 + const listenPort = port ?? this.config.server?.port ?? 3000 // If cluster mode is enabled and we're the master, start the cluster if (this.clusterManager && this.isClusterMaster) { @@ -863,9 +930,14 @@ export class BunGateway implements Gateway { if (this.tlsManager?.isRedirectEnabled()) { const redirectPort = this.tlsManager.getRedirectPort() if (redirectPort) { + const tlsConfig = this.config.security?.tls this.httpRedirectManager = new HTTPRedirectManager({ port: redirectPort, httpsPort: listenPort, + hostname: tlsConfig?.redirectAllowedHosts + ? undefined + : this.config.server?.hostname, + allowHosts: tlsConfig?.redirectAllowedHosts, logger: this.config.logger, }) this.httpRedirectManager.start() diff --git a/src/interfaces/gateway.ts b/src/interfaces/gateway.ts index 216ce53..d3e45fd 100644 --- a/src/interfaces/gateway.ts +++ b/src/interfaces/gateway.ts @@ -3,10 +3,11 @@ import type { RouteConfig } from './route' import type { BodyParserOptions, JWTAuthOptions, + RateLimitOptions, RequestHandler, ZeroRequest, } from './middleware' -import type { ProxyOptions } from './proxy' +import type { GatewayProxyOptions } from './proxy' import type { Logger } from './logger' import type { SecurityConfig } from '../security/config' @@ -148,7 +149,7 @@ export interface GatewayConfig { * Global proxy configuration applied to all routes * Settings here can be overridden by individual route configurations */ - proxy?: ProxyOptions + proxy?: GatewayProxyOptions /** * Cross-Origin Resource Sharing (CORS) configuration @@ -199,30 +200,7 @@ export interface GatewayConfig { * Rate limiting configuration to prevent abuse * Protects against excessive requests from clients */ - rateLimit?: { - /** - * Time window for rate limiting in milliseconds - * @default 900000 (15 minutes) - */ - windowMs?: number - /** - * Maximum number of requests per window - * @default 100 - */ - max?: number - /** - * Function to generate unique keys for rate limiting - * Used to identify clients (by IP, user ID, etc.) - * @default (req) => req.ip - */ - keyGenerator?: (req: ZeroRequest) => string - /** - * Include rate limit info in standard headers - * Adds X-RateLimit-* headers to responses - * @default true - */ - standardHeaders?: boolean - } + rateLimit?: RateLimitOptions /** * JWT (JSON Web Token) authentication configuration diff --git a/src/interfaces/load-balancer.ts b/src/interfaces/load-balancer.ts index 26ba9e8..44de5ab 100644 --- a/src/interfaces/load-balancer.ts +++ b/src/interfaces/load-balancer.ts @@ -96,17 +96,17 @@ export interface LoadBalancerConfig { * Health check interval in milliseconds * @default 30000 (30 seconds) */ - interval: number + interval?: number /** * Health check request timeout in milliseconds * @default 5000 (5 seconds) */ - timeout: number + timeout?: number /** * Health check endpoint path * @default '/health' */ - path: string + path?: string /** * Expected HTTP status code for healthy response * @default 200 @@ -172,6 +172,19 @@ export interface LoadBalancerConfig { * @default 3600000 (1 hour) */ ttl?: number + /** + * Maximum number of sticky sessions to retain in memory. + * When the cap is reached, the oldest session is evicted. + * @default 10000 + */ + maxSessions?: number + /** + * What to do when a client sends an unknown session cookie. + * - 'ignore': treat as if no cookie was sent (default, prevents DoS). + * - 'create': mint a new session for the unknown value. + * @default 'ignore' + */ + unknownCookiePolicy?: 'ignore' | 'create' } /** diff --git a/src/interfaces/middleware.ts b/src/interfaces/middleware.ts index d5fc247..a1a29e1 100644 --- a/src/interfaces/middleware.ts +++ b/src/interfaces/middleware.ts @@ -11,14 +11,12 @@ import type { IRouterConfig, ParsedFile, } from '0http-bun' - // Import all middleware types from 0http-bun for comprehensive middleware support import type { // Logger middleware for request/response logging LoggerOptions, - // JWT/Auth middleware for security - JWTAuthOptions, + // Auth middleware for security APIKeyAuthOptions, JWKSLike, TokenExtractionOptions, @@ -114,7 +112,6 @@ export type { LoggerOptions, // JWT/Auth middleware - JWTAuthOptions, APIKeyAuthOptions, JWKSLike, TokenExtractionOptions, @@ -141,6 +138,77 @@ export type { PrometheusIntegration, } +/** + * Gateway JWT authentication options. + * + * This is the internal, hardened interface. It intentionally requires `exp` + * on tokens, supports `audience`/`issuer`, and derives the allowed algorithm + * list from the supplied key type. + */ +/** Key material accepted by the hardened JWT middleware. */ +export type JWTKeyLike = CryptoKey | Uint8Array + +export interface JWTAuthOptions { + /** Symmetric secret, PEM string, JWTKeyLike, or a function resolving to a key */ + secret?: + | string + | Uint8Array + | JWTKeyLike + | ((req: ZeroRequest) => JWTKeyLike | Promise) + /** Remote JWKS URI */ + jwksUri?: string + /** Inline JWKS object or resolver */ + jwks?: any + /** Additional JWT verification options (merged, but cannot override security-critical ones) */ + jwtOptions?: Record + /** Custom token extractor */ + getToken?: ( + req: ZeroRequest, + ) => string | undefined | Promise + /** Header name to read the token from */ + tokenHeader?: string + /** Query parameter name to read the token from */ + tokenQuery?: string + /** If true, missing/invalid tokens do not block the request */ + optional?: boolean + /** Paths excluded from JWT checks. Matching uses boundary-aware semantics. */ + excludePaths?: string[] + /** Static API keys or validation function */ + apiKeys?: + | string[] + | (( + apiKey: string, + req: ZeroRequest, + ) => boolean | object | Promise) + /** Header name for API key authentication */ + apiKeyHeader?: string + /** Custom API key validator */ + apiKeyValidator?: ( + apiKey: string, + req: ZeroRequest, + ) => boolean | object | Promise + /** Deprecated alias for apiKeyValidator */ + validateApiKey?: ( + apiKey: string, + req: ZeroRequest, + ) => boolean | object | Promise + /** Custom 401 response or response factory */ + unauthorizedResponse?: + | Response + | ((error: Error, req: ZeroRequest) => Response | object) + /** Custom error handler */ + onError?: ( + error: Error, + req: ZeroRequest, + ) => Response | void | Promise + /** Required audience claim(s) */ + audience?: string | string[] + /** Required issuer claim */ + issuer?: string | string[] + /** Explicitly allowed algorithms (derived from key type when omitted) */ + algorithms?: string[] +} + /** * Middleware manager for organizing middleware execution * Uses imported types from 0http-bun diff --git a/src/interfaces/proxy.ts b/src/interfaces/proxy.ts index ef5c03d..c8280d9 100644 --- a/src/interfaces/proxy.ts +++ b/src/interfaces/proxy.ts @@ -45,6 +45,40 @@ export type { // Import ZeroRequest from our middleware types for gateway-specific interfaces import type { ZeroRequest } from './middleware' +/** + * Gateway proxy options extending fetch-gate with redirect security controls. + */ +export interface GatewayProxyOptions extends ProxyOptions { + /** + * Allowed redirect target hostnames when followRedirects is enabled. + * Supports leading wildcards, e.g. '*.example.com'. + * If empty and redirectSameOrigin is not false, only same-origin redirects + * (same scheme+host+port as the original upstream target) are allowed. + */ + redirectAllowlist?: string[] + + /** + * When true and no redirectAllowlist is provided, allow redirects only to + * the same origin as the original upstream target. + * @default true + */ + redirectSameOrigin?: boolean + + /** + * Rewrite incoming paths before forwarding to the upstream target. + * Keys are regex patterns; values are replacements. May also be a function + * that receives the original path (including query string) and returns the + * rewritten path. + */ + pathRewrite?: Record | ((path: string) => string) + + /** + * Per-request fetch `RequestInit` options (e.g. `redirect`). + * Used internally to force `redirect: 'manual'` for SSRF protection. + */ + request?: RequestInit +} + /** * Gateway-specific proxy handler interface * Extends fetch-gate functionality with enhanced ZeroRequest support diff --git a/src/interfaces/route.ts b/src/interfaces/route.ts index ad4966c..f3b13f3 100644 --- a/src/interfaces/route.ts +++ b/src/interfaces/route.ts @@ -4,6 +4,7 @@ import type { CircuitBreakerOptions, } from 'fetch-gate' import type { LoadBalancerConfig } from './load-balancer' +import type { GatewayProxyOptions } from './proxy' import type { JWTAuthOptions, RateLimitOptions, @@ -69,52 +70,7 @@ export interface RouteConfig { * Proxy configuration options (follows fetch-gate pattern) * Controls how requests are forwarded to backend services */ - proxy?: { - /** - * Additional headers to include in proxied requests - * These headers are merged with the original request headers - * @example { 'X-Forwarded-For': 'gateway', 'Authorization': 'Bearer token' } - */ - headers?: Record - - /** - * Request timeout in milliseconds for proxy requests - * @default 30000 (30 seconds) - */ - timeout?: number - - /** - * Whether to automatically follow HTTP redirects - * @default true - */ - followRedirects?: boolean - - /** - * Maximum number of redirects to follow - * @default 5 - */ - maxRedirects?: number - - /** - * Path rewriting rules for modifying the request path before forwarding - * @example - * - Object: { '^/api/v1': '/v1', '^/old': '/new' } - * - Function: (path) => path.replace('/api', '') - */ - pathRewrite?: Record | ((path: string) => string) - - /** - * Additional query parameters to append to proxied requests - * @example { 'version': '1.0', 'source': 'gateway' } or 'version=1.0&source=gateway' - */ - queryString?: Record | string - - /** - * Custom fetch options for the proxy request - * Allows fine-grained control over the request behavior - */ - request?: RequestInit - } + proxy?: GatewayProxyOptions /** * Lifecycle hooks for request/response processing (follows fetch-gate pattern) diff --git a/src/load-balancer/http-load-balancer.ts b/src/load-balancer/http-load-balancer.ts index c808d38..867c3e4 100644 --- a/src/load-balancer/http-load-balancer.ts +++ b/src/load-balancer/http-load-balancer.ts @@ -84,6 +84,8 @@ const MAX_HEALTH_CHECK_TIMEOUT = 60000 const MAX_HEALTH_THRESHOLD = 20 /** Maximum health check response body read size (bytes) */ const MAX_HEALTH_CHECK_BODY_SIZE = 4096 +/** Default sticky session cap */ +const DEFAULT_STICKY_SESSION_MAX_SESSIONS = 10000 /** * Returns a cryptographically secure random integer in [0, max). @@ -121,12 +123,18 @@ export class HttpLoadBalancer implements LoadBalancer { private totalRequests = 0 /** Health check interval timer */ private healthCheckInterval?: Timer + /** Whether a health-check round is currently running */ + private healthCheckInProgress = false /** Session tracking for sticky sessions */ private sessions = new Map() /** Session cleanup interval timer */ private sessionCleanupInterval?: Timer /** Session manager for cryptographically secure session handling */ private sessionManager?: SessionManager + /** Maximum sticky sessions retained in memory */ + private stickySessionMaxSessions = DEFAULT_STICKY_SESSION_MAX_SESSIONS + /** How to handle unknown sticky-session cookies */ + private stickySessionUnknownPolicy: 'ignore' | 'create' = 'ignore' /** Logger instance for monitoring and debugging */ private logger: Logger /** Trusted proxy validator for secure client IP extraction */ @@ -151,6 +159,16 @@ export class HttpLoadBalancer implements LoadBalancer { // Validate and clamp health-check configuration this.validateHealthCheckConfig() + // Apply sticky-session bounds + if (config.stickySession?.enabled) { + this.stickySessionMaxSessions = Math.max( + 1, + config.stickySession.maxSessions ?? DEFAULT_STICKY_SESSION_MAX_SESSIONS, + ) + this.stickySessionUnknownPolicy = + config.stickySession.unknownCookiePolicy ?? 'ignore' + } + this.logger.info('Load balancer initialized', { strategy: config.strategy, targetCount: config.targets.length, @@ -198,6 +216,14 @@ export class HttpLoadBalancer implements LoadBalancer { const hc = this.config.healthCheck if (!hc?.enabled) return + // Apply defaults first so missing fields do not produce NaN later. + hc.interval ??= 10000 + hc.timeout ??= 5000 + hc.failureThreshold ??= 3 + hc.successThreshold ??= 2 + hc.minHealthyTargets ??= 1 + hc.path ??= '/health' + let interval = hc.interval let timeout = hc.timeout @@ -287,6 +313,9 @@ export class HttpLoadBalancer implements LoadBalancer { const stickyResult = this.getStickyTarget(request) if (stickyResult?.target) { this.recordRequest(stickyResult.target.url) + // Refresh TTL and re-issue the cookie on every hit so active sessions + // do not expire prematurely (V-19). + stickySetCookie = this.refreshStickySession(stickyResult.sessionId!) this.logger.logLoadBalancing( this.config.strategy, stickyResult.target.url, @@ -296,6 +325,11 @@ export class HttpLoadBalancer implements LoadBalancer { healthyTargets: healthyTargets.length, }, ) + Object.defineProperty(stickyResult.target, '__stickySetCookie', { + value: stickySetCookie, + enumerable: false, + configurable: true, + }) return stickyResult.target } } @@ -449,7 +483,10 @@ export class HttpLoadBalancer implements LoadBalancer { * @param healthy - New health status */ updateTargetHealth(url: string, healthy: boolean): void { - const minHealthyTargets = this.config.healthCheck?.minHealthyTargets ?? 1 + const minHealthyTargets = Math.min( + this.config.healthCheck?.minHealthyTargets ?? 1, + Math.max(1, this.targets.size), + ) if (healthy) { this.applyHealthState(url, true) } else { @@ -540,11 +577,14 @@ export class HttpLoadBalancer implements LoadBalancer { return } - const interval = this.config.healthCheck.interval + const interval = this.config.healthCheck.interval! // Add jitter (up to 10% of interval) to prevent thundering herds const jitter = Math.floor(secureRandom() * interval * 0.1) this.healthCheckInterval = setInterval(() => { - this.performHealthChecks() + // Skip a round if the previous one is still running (V-14). + if (!this.healthCheckInProgress) { + this.performHealthChecks() + } }, interval + jitter) } @@ -769,25 +809,27 @@ export class HttpLoadBalancer implements LoadBalancer { private getStickyTarget(request: Request): { target: LoadBalancerTarget | null + sessionId: string | null } { if (!this.config.stickySession?.enabled) { - return { target: null } + return { target: null, sessionId: null } } const sessionId = this.getSessionId(request) if (!sessionId) { - return { target: null } + return { target: null, sessionId: null } } const session = this.sessions.get(sessionId) if (!session || Date.now() > session.expiresAt) { // Unknown / expired session IDs are not allowed to create new affinity. - return { target: null } + return { target: null, sessionId: null } } const target = this.targets.get(session.targetUrl) return { target: target && target.healthy ? this.sanitizeTarget(target) : null, + sessionId, } } @@ -801,20 +843,18 @@ export class HttpLoadBalancer implements LoadBalancer { const existingSessionId = this.getSessionId(request) - // If the client already has a valid session for this target, refresh it - if (existingSessionId) { - const existing = this.sessions.get(existingSessionId) - if ( - existing && - existing.targetUrl === target.url && - Date.now() <= existing.expiresAt - ) { - existing.expiresAt = - Date.now() + (this.config.stickySession.ttl ?? 3600000) - return this.buildStickySetCookie(existingSessionId) + // Unknown or expired session cookies must not be allowed to mint new + // sessions unless explicitly configured (V-3). This prevents attackers + // from flooding memory with random cookie values. + if (existingSessionId && !this.sessions.has(existingSessionId)) { + if (this.stickySessionUnknownPolicy === 'ignore') { + return undefined } } + // Enforce the maximum in-memory session cap (V-3). + this.evictOldestSessionIfNeeded() + // Generate a new cryptographically secure session ID server-side const sessionId = this.generateSessionId() const ttl = this.config.stickySession.ttl ?? 3600000 // 1 hour default @@ -829,9 +869,48 @@ export class HttpLoadBalancer implements LoadBalancer { return this.buildStickySetCookie(sessionId) } + /** + * Refresh an existing sticky session's TTL and return a new Set-Cookie header. + */ + private refreshStickySession(sessionId: string): string | undefined { + const session = this.sessions.get(sessionId) + if (!session) return undefined + const ttl = this.config.stickySession?.ttl ?? 3600000 + session.expiresAt = Date.now() + ttl + return this.buildStickySetCookie(sessionId) + } + + /** + * Evict the oldest session when the cap is reached. + */ + private evictOldestSessionIfNeeded(): void { + if (this.sessions.size < this.stickySessionMaxSessions) { + return + } + let oldestId: string | null = null + let oldestTime = Number.POSITIVE_INFINITY + for (const [id, session] of this.sessions.entries()) { + if (session.createdAt < oldestTime) { + oldestTime = session.createdAt + oldestId = id + } + } + if (oldestId) { + this.sessions.delete(oldestId) + } + } + private buildStickySetCookie(sessionId: string): string { const cookieName = this.config.stickySession?.cookieName ?? 'lb-session' const ttl = this.config.stickySession?.ttl ?? 3600000 + + if (!/^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/.test(cookieName)) { + throw new Error(`Invalid sticky-session cookie name: ${cookieName}`) + } + if (!Number.isFinite(ttl) || ttl < 0) { + throw new Error(`Invalid sticky-session TTL: ${ttl}`) + } + const parts: string[] = [`${cookieName}=${sessionId}`] parts.push(`Max-Age=${Math.floor(ttl / 1000)}`) parts.push('Path=/') @@ -929,9 +1008,10 @@ export class HttpLoadBalancer implements LoadBalancer { */ private async performHealthChecks(): Promise { const healthCheckConfig = this.config.healthCheck - if (!healthCheckConfig?.enabled) { + if (!healthCheckConfig?.enabled || this.healthCheckInProgress) { return } + this.healthCheckInProgress = true const failureThreshold = healthCheckConfig.failureThreshold ?? 3 const successThreshold = healthCheckConfig.successThreshold ?? 2 @@ -952,6 +1032,7 @@ export class HttpLoadBalancer implements LoadBalancer { const promises = Array.from(this.targets.values()).map( async (target: InternalTarget) => { const startTime = Date.now() + let timeoutId: ReturnType | undefined try { const url = new URL(target.url) @@ -982,12 +1063,12 @@ export class HttpLoadBalancer implements LoadBalancer { } } - url.pathname = healthCheckConfig.path + url.pathname = healthCheckConfig.path! const controller = new AbortController() - const timeoutId = setTimeout( + timeoutId = setTimeout( () => controller.abort(), - healthCheckConfig.timeout, + healthCheckConfig.timeout!, ) const method = healthCheckConfig.method ?? 'GET' @@ -1001,17 +1082,19 @@ export class HttpLoadBalancer implements LoadBalancer { redirect: 'manual', }) - clearTimeout(timeoutId) const duration = Date.now() - startTime const isHealthy = response.status === (healthCheckConfig.expectedStatus ?? 200) - // Check response body if expected, with bounded read size + // Check response body if expected, with bounded read size. + // The abort timeout is kept active until the body is fully consumed + // so a slow-trickling response cannot hold the probe open forever (V-14). if (isHealthy && healthCheckConfig.expectedBody) { const body = await this.readBoundedResponse( response, MAX_HEALTH_CHECK_BODY_SIZE, + controller.signal, ) const bodyMatches = body.includes(healthCheckConfig.expectedBody) @@ -1083,11 +1166,17 @@ export class HttpLoadBalancer implements LoadBalancer { duration, error as Error, ) + } finally { + if (timeoutId) clearTimeout(timeoutId) } }, ) - await Promise.allSettled(promises) + try { + await Promise.allSettled(promises) + } finally { + this.healthCheckInProgress = false + } } /** @@ -1096,12 +1185,24 @@ export class HttpLoadBalancer implements LoadBalancer { private async readBoundedResponse( response: Response, maxBytes: number, + signal?: AbortSignal, ): Promise { const reader = response.body?.getReader() if (!reader) { return '' } + const onAbort = signal + ? () => { + try { + reader.cancel('health check timeout') + } catch { + // ignore + } + } + : undefined + if (onAbort) signal!.addEventListener('abort', onAbort, { once: true }) + const chunks: Uint8Array[] = [] let total = 0 try { @@ -1115,6 +1216,7 @@ export class HttpLoadBalancer implements LoadBalancer { total += chunk.length } } finally { + if (onAbort) signal!.removeEventListener('abort', onAbort) reader.releaseLock() } diff --git a/src/logger/pino-logger.ts b/src/logger/pino-logger.ts index 52ff611..3e798ec 100644 --- a/src/logger/pino-logger.ts +++ b/src/logger/pino-logger.ts @@ -79,6 +79,14 @@ export class BunGateLogger implements Logger { 'headers["X-Api-Key"]', 'headers.authorization', 'headers.Authorization', + 'headers.cookie', + 'headers["set-cookie"]', + 'request.headers.authorization', + 'request.headers.cookie', + 'request.headers["set-cookie"]', + 'response.headers.authorization', + 'response.headers.cookie', + 'response.headers["set-cookie"]', // JWT tokens 'token', 'accessToken', @@ -169,6 +177,7 @@ export class BunGateLogger implements Logger { 'ccv', 'ssn', 'social_security', + 'cookie', ] for (const key in sanitized) { diff --git a/src/proxy/gateway-proxy.ts b/src/proxy/gateway-proxy.ts index c058f35..c73fbde 100644 --- a/src/proxy/gateway-proxy.ts +++ b/src/proxy/gateway-proxy.ts @@ -10,37 +10,51 @@ * - Connection pooling and keep-alive for performance * - Request/response transformation capabilities * - Comprehensive error handling and retry logic - * - Real-time health monitoring and metrics + * - Real-time health monitoring and metrics collection * - Support for ZeroRequest enhanced context - * - * @example - * ```ts - * const proxy = createGatewayProxy({ - * timeout: 5000, - * circuitBreaker: { - * errorThreshold: 50, - * resetTimeout: 30000 - * }, - * hooks: { - * beforeRequest: (req) => { - * req.headers.set('X-Gateway', 'bungate') - * } - * } - * }) - * - * const response = await proxy.proxy(request, 'http://backend-service:3000') - * ``` + * - Secure redirect handling (manual by default, opt-in with allowlist/same-origin) */ import type { ProxyHandler, ProxyInstance } from '../interfaces/proxy' -import type { - ProxyOptions, - ProxyRequestOptions, - CircuitState, -} from 'fetch-gate' +import type { ProxyRequestOptions, CircuitState } from 'fetch-gate' +import type { GatewayProxyOptions } from '../interfaces/proxy' import { FetchProxy } from 'fetch-gate/lib/proxy' import type { ZeroRequest } from '../interfaces/middleware' +export function resolveTargetUrl(source?: string, base?: string): URL { + const target = source || '' + if (target.includes('://')) return new URL(target) + const baseUrl = base || '' + if (baseUrl.includes('://')) return new URL(target, baseUrl) + // Fallback used only for malformed configurations. + return new URL(target || '/', 'http://localhost') +} + +export function matchesHostname(hostname: string, pattern: string): boolean { + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(2).toLowerCase() + const h = hostname.toLowerCase() + return h === suffix || h.endsWith('.' + suffix) + } + return hostname.toLowerCase() === pattern.toLowerCase() +} + +export function isRedirectAllowed( + redirectUrl: URL, + originalUrl: URL, + options: GatewayProxyOptions, +): boolean { + if (options.redirectAllowlist && options.redirectAllowlist.length > 0) { + return options.redirectAllowlist.some((allowed) => + matchesHostname(redirectUrl.hostname, allowed), + ) + } + if (options.redirectSameOrigin === false) { + return false + } + return redirectUrl.origin === originalUrl.origin +} + /** * Gateway-enhanced proxy handler with ZeroRequest support * @@ -51,13 +65,16 @@ import type { ZeroRequest } from '../interfaces/middleware' export class GatewayProxy implements ProxyHandler { /** Underlying fetch-gate proxy instance for core functionality */ private fetchProxy: FetchProxy + /** Resolved gateway proxy options */ + private options: GatewayProxyOptions /** * Initialize the gateway proxy with fetch-gate options * * @param options - Proxy configuration including timeouts, circuit breaker, and hooks */ - constructor(options: ProxyOptions) { + constructor(options: GatewayProxyOptions) { + this.options = options this.fetchProxy = new FetchProxy(options) } @@ -74,8 +91,67 @@ export class GatewayProxy implements ProxyHandler { source?: string, opts?: ProxyRequestOptions, ): Promise { - // Cast ZeroRequest to standard Request for fetch-gate compatibility - return this.fetchProxy.proxy(req as Request, source, opts) + const mergedOptions: GatewayProxyOptions = { ...this.options, ...opts } + const followRedirects = mergedOptions.followRedirects === true + const maxRedirects = mergedOptions.maxRedirects ?? 5 + + let currentReq = req as Request + let originalTargetUrl: URL | undefined + let redirectCount = 0 + + while (true) { + // Always use manual redirect mode so bungate controls SSRF exposure (V-1). + const proxyOptions = { + ...mergedOptions, + request: { + ...(mergedOptions.request || {}), + redirect: 'manual' as const, + }, + } + + const response = await this.fetchProxy.proxy( + currentReq as Request, + source, + proxyOptions, + ) + + const isRedirect = + response.status >= 300 && + response.status < 400 && + response.headers.has('location') + + if (!isRedirect || !followRedirects || redirectCount >= maxRedirects) { + return response + } + + // Body-preserving redirects are unsafe to follow automatically because + // fetch-gate may have already consumed the stream. Follow GET/HEAD + // redirects only; return the 3xx response for other methods. + if (currentReq.method !== 'GET' && currentReq.method !== 'HEAD') { + return response + } + + const location = response.headers.get('location')! + const currentUrl = resolveTargetUrl( + source, + mergedOptions.base || this.options.base, + ) + if (originalTargetUrl === undefined) { + originalTargetUrl = currentUrl + } + const redirectUrl = new URL(location, currentUrl) + + if (!isRedirectAllowed(redirectUrl, originalTargetUrl, mergedOptions)) { + return response + } + + // Prepare next request. For GET/HEAD we can safely re-issue as GET. + currentReq = new Request(redirectUrl.toString(), { + method: 'GET', + headers: currentReq.headers, + }) + redirectCount++ + } } /** @@ -133,7 +209,9 @@ export class GatewayProxy implements ProxyHandler { * const response = await proxy.proxy(req, 'http://api.service.com') * ``` */ -export function createGatewayProxy(options: ProxyOptions): ProxyInstance { +export function createGatewayProxy( + options: GatewayProxyOptions, +): ProxyInstance { const handler = new GatewayProxy(options) return { proxy: handler.proxy.bind(handler), diff --git a/src/security/config.ts b/src/security/config.ts index 744c005..a30285c 100644 --- a/src/security/config.ts +++ b/src/security/config.ts @@ -19,6 +19,12 @@ export interface TLSConfig { rejectUnauthorized?: boolean redirectHTTP?: boolean redirectPort?: number + /** + * Allowed Host header values for the HTTP->HTTPS redirect server. + * Supports exact hostnames and leading wildcards (e.g. '*.example.com'). + * If not set, the redirect server uses the gateway's configured hostname. + */ + redirectAllowedHosts?: string[] } /** @@ -30,6 +36,8 @@ export interface ErrorHandlerConfig { logErrors?: boolean customMessages?: Record sanitizeBackendErrors?: boolean + /** Optional logger to receive error log entries. Defaults to the gateway logger. */ + logger?: any } /** @@ -57,6 +65,17 @@ export interface TrustedProxyConfig { trustedNetworks?: string[] maxForwardedDepth?: number trustAll?: boolean + /** + * Honor the Cloudflare-specific CF-Connecting-IP header. + * Only enable when the immediate proxy is a Cloudflare edge. + * @default false + */ + trustCloudflare?: boolean + /** + * Honor the X-Real-IP / X-Client-IP headers from the trusted immediate proxy. + * @default false + */ + trustXRealIP?: boolean } /** diff --git a/src/security/error-handler-middleware.ts b/src/security/error-handler-middleware.ts index a9d11b6..bf6f86d 100644 --- a/src/security/error-handler-middleware.ts +++ b/src/security/error-handler-middleware.ts @@ -209,12 +209,13 @@ export function createProductionErrorHandler(): RequestHandler { * Creates a development error handler middleware * * This is a convenience function that creates middleware with - * development-friendly defaults including stack traces. + * development-friendly defaults. Stack traces are still excluded from HTTP + * responses by default; set includeStackTrace to true explicitly when needed. */ export function createDevelopmentErrorHandler(): RequestHandler { return createErrorHandlerMiddleware({ production: false, - includeStackTrace: true, + includeStackTrace: false, logErrors: true, sanitizeBackendErrors: false, }) diff --git a/src/security/error-handler.ts b/src/security/error-handler.ts index 5d37f40..1fb01f2 100644 --- a/src/security/error-handler.ts +++ b/src/security/error-handler.ts @@ -12,6 +12,7 @@ import { generateRequestId, redactSensitiveData, } from './utils' +import { defaultLogger } from '../logger/pino-logger' /** * Default error messages for common HTTP status codes @@ -51,6 +52,7 @@ export class SecureErrorHandler { logErrors: config?.logErrors ?? true, customMessages: config?.customMessages ?? {}, sanitizeBackendErrors: config?.sanitizeBackendErrors ?? true, + logger: config?.logger ?? defaultLogger, } // In production, never include stack traces @@ -152,20 +154,30 @@ export class SecureErrorHandler { method: context.method, url: context.url, clientIP: context.clientIP, - headers: this.config.production - ? redactSensitiveData(context.headers || {}) - : context.headers, + headers: redactSensitiveData(context.headers || {}), }, } - // Use console for now - can be replaced with proper logger + // Route through the configured logger so redaction and transport settings + // are respected (V-23). Fallback to console only if the logger rejects. const logLevel = logEntry.level - if (logLevel === 'critical' || logLevel === 'error') { - console.error('[SecureErrorHandler]', JSON.stringify(logEntry, null, 2)) - } else if (logLevel === 'warn') { - console.warn('[SecureErrorHandler]', JSON.stringify(logEntry, null, 2)) - } else { - console.log('[SecureErrorHandler]', JSON.stringify(logEntry, null, 2)) + const logger = this.config.logger ?? defaultLogger + try { + if (logLevel === 'critical' || logLevel === 'error') { + logger.error(logEntry, 'SecureErrorHandler error') + } else if (logLevel === 'warn') { + logger.warn(logEntry, 'SecureErrorHandler warning') + } else { + logger.info(logEntry, 'SecureErrorHandler info') + } + } catch { + if (logLevel === 'critical' || logLevel === 'error') { + console.error('[SecureErrorHandler]', JSON.stringify(logEntry, null, 2)) + } else if (logLevel === 'warn') { + console.warn('[SecureErrorHandler]', JSON.stringify(logEntry, null, 2)) + } else { + console.log('[SecureErrorHandler]', JSON.stringify(logEntry, null, 2)) + } } } diff --git a/src/security/http-redirect.ts b/src/security/http-redirect.ts index 895973d..489512d 100644 --- a/src/security/http-redirect.ts +++ b/src/security/http-redirect.ts @@ -15,12 +15,26 @@ export interface HTTPRedirectConfig { port: number /** HTTPS port to redirect to */ httpsPort: number - /** Optional hostname for redirect (defaults to request hostname) */ + /** Optional hostname for redirect (overrides request hostname) */ hostname?: string + /** Allowed request Host header values. Supports leading wildcards. */ + allowHosts?: string[] /** Logger instance */ logger?: Logger } +export function matchesAllowedHost( + requestHost: string, + allowed: string, +): boolean { + if (allowed.startsWith('*.')) { + const suffix = allowed.slice(2).toLowerCase() + const h = requestHost.toLowerCase() + return h === suffix || h.endsWith('.' + suffix) + } + return requestHost.toLowerCase() === allowed.toLowerCase() +} + /** * Creates an HTTP redirect server that redirects all requests to HTTPS * @@ -37,7 +51,7 @@ export interface HTTPRedirectConfig { * ``` */ export function createHTTPRedirectServer(config: HTTPRedirectConfig): Server { - const { port, httpsPort, hostname, logger } = config + const { port, httpsPort, hostname, allowHosts, logger } = config const server = Bun.serve({ port, @@ -45,17 +59,47 @@ export function createHTTPRedirectServer(config: HTTPRedirectConfig): Server { const url = new URL(req.url) // Validate the incoming Host header. When an explicit hostname is - // configured, always use it. Otherwise only accept the configured - // hostname; do not use arbitrary request Host headers to avoid - // open-redirect / cache-poisoning attacks. + // configured, always use it. Otherwise only allow hosts listed in + // allowHosts. Arbitrary request Host headers must not be used for the + // redirect Location to prevent open-redirect / cache-poisoning (V-4). const requestHost = url.hostname const redirectHost = hostname ?? requestHost - if (hostname && requestHost !== '' && requestHost !== hostname) { - logger?.warn?.('Rejected HTTP redirect for unexpected host', { - expected: hostname, - received: requestHost, - }) + if (hostname) { + if (requestHost !== '' && requestHost !== hostname) { + logger?.warn?.('Rejected HTTP redirect for unexpected host', { + expected: hostname, + received: requestHost, + }) + return new Response(JSON.stringify({ error: 'Bad Request' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + } + } else if (allowHosts && allowHosts.length > 0) { + if ( + requestHost === '' || + !allowHosts.some((allowed) => + matchesAllowedHost(requestHost, allowed), + ) + ) { + logger?.warn?.('Rejected HTTP redirect for disallowed host', { + allowed: allowHosts, + received: requestHost, + }) + return new Response(JSON.stringify({ error: 'Bad Request' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + } + } else { + // No hostname or allow-list configured: do not redirect. + logger?.warn?.( + 'Rejected HTTP redirect: no hostname or allowHosts configured', + { + received: requestHost, + }, + ) return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: { 'content-type': 'application/json' }, diff --git a/src/security/index.ts b/src/security/index.ts index 467b55f..caeb594 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -49,6 +49,7 @@ export { sanitizeHeader, containsOnlyAllowedChars, matchesBlockedPattern, + matchesExcludedPath, sanitizeErrorMessage, generateRequestId, isValidIP, @@ -147,3 +148,6 @@ export { createTokenVerifier, type JWTKeyRotationMiddlewareOptions, } from './jwt-key-rotation-middleware' + +// Export hardened JWT auth middleware +export { createJWTAuth } from './jwt-auth' diff --git a/src/security/jwt-auth.ts b/src/security/jwt-auth.ts new file mode 100644 index 0000000..6f17c5f --- /dev/null +++ b/src/security/jwt-auth.ts @@ -0,0 +1,375 @@ +/** + * Hardened JWT authentication middleware. + * + * Replaces the 0http-bun JWT middleware with an internal implementation that: + * - Requires an `exp` claim. + * - Supports `audience` / `issuer` validation. + * - Derives the allowed algorithm list from the supplied key type. + * - Rejects PEM-like secrets for HMAC algorithms (prevents algorithm confusion). + * - Enforces minimum HMAC key lengths. + * - Uses boundary-aware `excludePaths` matching. + */ + +import { + jwtVerify, + importSPKI, + importPKCS8, + createRemoteJWKSet, + errors as joseErrors, + type JWTVerifyGetKey, +} from 'jose' +import type { ZeroRequest } from '0http-bun' +import type { + JWTAuthOptions, + JWTKeyLike, + StepFunction, +} from '../interfaces/middleware' +import { matchesExcludedPath } from './utils' + +const MIN_HMAC_BYTES: Record = { + HS256: 32, + HS384: 48, + HS512: 64, +} + +const PEM_HEADER = /-----BEGIN (?:RSA |EC )?(?:PUBLIC|PRIVATE) KEY-----/ + +const RSA_ALGORITHMS = ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'] +const EC_ALGORITHMS = ['ES256', 'ES384', 'ES512'] +const OKP_ALGORITHMS = ['EdDSA'] +const SYMMETRIC_ALGORITHMS = ['HS256', 'HS384', 'HS512'] +const ALL_SIGNED_ALGORITHMS = [ + ...SYMMETRIC_ALGORITHMS, + ...RSA_ALGORITHMS, + ...EC_ALGORITHMS, + ...OKP_ALGORITHMS, +] + +function isPEM(value: string | Uint8Array): value is string { + return typeof value === 'string' && PEM_HEADER.test(value) +} + +function isSymmetricSecret(secret: unknown): secret is string | Uint8Array { + return typeof secret === 'string' || secret instanceof Uint8Array +} + +function validateHMACSecret(secret: string | Uint8Array, algorithms: string[]) { + const len = typeof secret === 'string' ? secret.length : secret.byteLength + for (const alg of algorithms) { + const min = MIN_HMAC_BYTES[alg] + if (min && len < min) { + throw new Error( + `HMAC secret too short for ${alg}: ${len} bytes, minimum ${min} bytes`, + ) + } + } +} + +async function importSecret(secret: string | Uint8Array): Promise { + if (isPEM(secret)) { + const trimmed = secret.trim() + if (trimmed.includes('PUBLIC KEY')) { + return importSPKI(trimmed, 'RS256') + } + if (trimmed.includes('PRIVATE KEY')) { + return importPKCS8(trimmed, 'RS256') + } + throw new Error('Unable to determine PEM key type') + } + return typeof secret === 'string' ? new TextEncoder().encode(secret) : secret +} + +function detectKeyAlgorithms(secret: unknown, algorithms?: string[]): string[] { + if (algorithms) { + if (algorithms.includes('none')) { + throw new Error('Algorithm "none" is not allowed') + } + return algorithms + } + + if (typeof secret === 'function') { + return ALL_SIGNED_ALGORITHMS + } + + if (isSymmetricSecret(secret)) { + if (isPEM(secret)) { + if (secret.includes('EC ')) return [...EC_ALGORITHMS] + return [...RSA_ALGORITHMS] + } + return ['HS256'] + } + + // KeyLike object. We cannot reliably inspect it without node:crypto internals, + // so allow the common signed algorithms and let jose reject mismatches. + return ALL_SIGNED_ALGORITHMS +} + +async function validateApiKey( + apiKey: string, + options: JWTAuthOptions, + req: ZeroRequest, +): Promise { + const validator = options.apiKeyValidator || options.validateApiKey + if (validator) { + return validator.length === 1 + ? await (validator as (apiKey: string) => boolean | object)(apiKey) + : await validator(apiKey, req) + } + + const { apiKeys } = options + if (typeof apiKeys === 'function') { + return await apiKeys(apiKey, req) + } + if (Array.isArray(apiKeys)) { + return apiKeys.includes(apiKey) + } + return false +} + +async function extractToken( + req: ZeroRequest, + options: JWTAuthOptions, +): Promise { + if (options.getToken) { + const token = await options.getToken(req) + if (token) return token + } + + if (options.tokenHeader) { + const token = req.headers.get(options.tokenHeader) + if (token) return token + } + + if (options.tokenQuery) { + const url = new URL(req.url) + const token = url.searchParams.get(options.tokenQuery) + if (token) return token + } + + const authorization = req.headers.get('authorization') + if (!authorization) return undefined + + const parts = authorization.split(' ') + if (parts.length !== 2 || parts[0]!.toLowerCase() !== 'bearer') + return undefined + return parts[1] +} + +interface ResponseLike { + body?: unknown + status?: number + headers?: Record | Headers +} + +function buildUnauthorizedResponse( + error: Error, + options: JWTAuthOptions, + req: ZeroRequest, +) { + const { unauthorizedResponse } = options + if (unauthorizedResponse) { + try { + if (unauthorizedResponse instanceof Response) return unauthorizedResponse + if (typeof unauthorizedResponse === 'function') { + const result = unauthorizedResponse(error, req) + if (result instanceof Response) return result + if (result && typeof result === 'object') { + const responseLike = result as ResponseLike + const body = responseLike.body + return new Response( + typeof body === 'string' ? body : JSON.stringify(body || result), + { + status: responseLike.status || 401, + headers: responseLike.headers || { + 'content-type': 'application/json', + }, + }, + ) + } + } + } catch { + // fall through to default + } + } + + let message = 'Invalid token' + if (error.message === 'Authentication required') { + message = 'Authentication required' + } else if (error.message === 'Invalid API key') { + message = 'Invalid API key' + } else if (error instanceof joseErrors.JWTExpired) { + message = 'Token expired' + } else if (error instanceof joseErrors.JWTInvalid) { + message = 'Invalid token format' + } else if (error instanceof joseErrors.JWKSNoMatchingKey) { + message = 'Token signature verification failed' + } else if (error.message.includes('required "exp" claim')) { + message = 'Token expired' + } else if ( + error.message.includes('audience') || + error.message.includes('"aud"') + ) { + message = 'Invalid token audience' + } else if ( + error.message.includes('issuer') || + error.message.includes('"iss"') + ) { + message = 'Invalid token issuer' + } + + return new Response(JSON.stringify({ error: message }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) +} + +export function createJWTAuth(options: JWTAuthOptions = {}) { + const { + secret, + jwksUri, + jwks, + jwtOptions = {}, + optional = false, + excludePaths = [], + apiKeyHeader = 'x-api-key', + audience, + issuer, + algorithms: userAlgorithms, + } = options + + const hasApiKeyMode = + options.apiKeys || options.apiKeyValidator || options.validateApiKey + + if (!secret && !jwksUri && !jwks && !hasApiKeyMode) { + throw new Error( + 'JWT middleware requires either secret, jwksUri, jwks, or apiKeys', + ) + } + + // Derive algorithm allowlist from key type *before* importing. + const algorithms = detectKeyAlgorithms(secret, userAlgorithms) + + if (isSymmetricSecret(secret) && !isPEM(secret)) { + validateHMACSecret(secret, algorithms) + } + + // Resolve the verification key eagerly so configuration errors surface at startup. + let keyResolver: + | JWTKeyLike + | Promise + | JWTVerifyGetKey + | undefined + + if (jwks) { + if (typeof jwks.getKey === 'function') { + keyResolver = async (protectedHeader, token) => + jwks.getKey(protectedHeader, token) + } else { + keyResolver = jwks as JWTVerifyGetKey + } + } else if (jwksUri) { + keyResolver = createRemoteJWKSet(new URL(jwksUri)) + } else if (typeof secret === 'function') { + keyResolver = async (protectedHeader, token) => { + const key = await (secret as any)(protectedHeader, token) + return key as JWTKeyLike + } + } else if (secret) { + keyResolver = isSymmetricSecret(secret) + ? importSecret(secret) + : (secret as JWTKeyLike) + } + + const verifyOptions = { + audience, + issuer, + ...jwtOptions, + // Security-critical options cannot be overridden by jwtOptions. + algorithms: jwtOptions.algorithms ?? algorithms, + requiredClaims: ['exp'], + } + + return async function jwtAuthMiddleware( + req: ZeroRequest, + next: StepFunction, + ) { + const url = new URL(req.url) + + if (matchesExcludedPath(url.pathname, excludePaths)) { + return next() + } + + try { + if (hasApiKeyMode) { + const apiKey = req.headers.get(apiKeyHeader) + if (apiKey) { + const validationResult = await validateApiKey(apiKey, options, req) + if (validationResult !== false) { + req.ctx = req.ctx || {} + req.ctx.apiKey = apiKey + req.ctx.user = + validationResult && typeof validationResult === 'object' + ? validationResult + : { apiKey } + req.apiKey = apiKey + req.user = req.ctx.user + return next() + } + return buildUnauthorizedResponse( + new Error('Invalid API key'), + options, + req, + ) + } + } + + const token = await extractToken(req, options) + if (!token) { + if (optional) return next() + return buildUnauthorizedResponse( + new Error('Authentication required'), + options, + req, + ) + } + + if (!keyResolver) { + return buildUnauthorizedResponse( + new Error('JWT verification not configured'), + options, + req, + ) + } + + const key = await Promise.resolve(keyResolver) + // jose's jwtVerify accepts both a static key and a JWTVerifyGetKey + // resolver; the union type does not satisfy either overload directly. + const { payload, protectedHeader } = await jwtVerify( + token, + key as Parameters[1], + verifyOptions, + ) + + req.ctx = req.ctx || {} + req.ctx.user = payload + req.ctx.jwt = { payload, header: protectedHeader, token } + req.user = payload + req.jwt = req.ctx.jwt + + return next() + } catch (error) { + if (optional && (!hasApiKeyMode || !req.headers.get(apiKeyHeader))) { + return next() + } + + if (options.onError) { + const result = await options.onError(error as Error, req) + if (result instanceof Response) return result + } + + return buildUnauthorizedResponse(error as Error, options, req) + } + } +} + +export default createJWTAuth diff --git a/src/security/jwt-key-rotation.ts b/src/security/jwt-key-rotation.ts index 6b4f528..5394d2b 100644 --- a/src/security/jwt-key-rotation.ts +++ b/src/security/jwt-key-rotation.ts @@ -71,8 +71,16 @@ export class JWTKeyRotationManager { * Validates the JWT key configuration */ private validateConfig(): void { + const hasJwks = !!this.config.jwksUri + if (!this.config.secrets || this.config.secrets.length === 0) { - throw new Error('At least one JWT secret must be configured') + if (!hasJwks) { + throw new Error( + 'At least one JWT secret or a jwksUri must be configured', + ) + } + // JWKS-only configuration is valid for verification. + return } const primaryKeys = this.config.secrets.filter((s) => s.primary) diff --git a/src/security/tls-manager.ts b/src/security/tls-manager.ts index d65b88d..52b5366 100644 --- a/src/security/tls-manager.ts +++ b/src/security/tls-manager.ts @@ -10,7 +10,19 @@ import type { TLSConfig } from './config' import type { ValidationResult } from './types' /** - * Bun TLS options interface + * OpenSSL SSL_OP_* bitmasks used for Bun's `secureOptions` TLS setting. + */ +const SSL_OP_NO_SSLv2 = 0x01000000 +const SSL_OP_NO_SSLv3 = 0x02000000 +const SSL_OP_NO_TLSv1 = 0x04000000 +const SSL_OP_NO_TLSv1_1 = 0x08000000 +const SSL_OP_NO_TLSv1_2 = 0x10000000 + +/** + * Bun TLS options interface. + * + * Note: Bun does not expose `minVersion` or `cipherSuites` keys. They are + * mapped here to `secureOptions` and `ciphers` respectively. */ export interface BunTLSOptions { cert?: string | Buffer @@ -18,8 +30,8 @@ export interface BunTLSOptions { ca?: string | Buffer passphrase?: string dhParamsFile?: string - minVersion?: 'TLSv1.2' | 'TLSv1.3' - cipherSuites?: string[] + secureOptions?: number + ciphers?: string requestCert?: boolean rejectUnauthorized?: boolean } @@ -181,12 +193,19 @@ export class TLSManager { const options: BunTLSOptions = { ...this.tlsOptions } + // Map the high-level minVersion option to Bun's secureOptions bitmask (V-10). if (this.config.minVersion) { - options.minVersion = this.config.minVersion + let secureOptions = + SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3 | SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 + if (this.config.minVersion === 'TLSv1.3') { + secureOptions |= SSL_OP_NO_TLSv1_2 + } + options.secureOptions = secureOptions } + // Map cipher suites to Bun's `ciphers` OpenSSL string (V-10). if (this.config.cipherSuites && this.config.cipherSuites.length > 0) { - options.cipherSuites = this.config.cipherSuites + options.ciphers = this.config.cipherSuites.join(':') } if (this.config.requestCert != null) { diff --git a/src/security/trusted-proxy.ts b/src/security/trusted-proxy.ts index 8a68487..0e41d30 100644 --- a/src/security/trusted-proxy.ts +++ b/src/security/trusted-proxy.ts @@ -351,34 +351,38 @@ export class TrustedProxyValidator { } } - // Prefer Cloudflare-signed header when behind Cloudflare - const cfConnectingIP = headers.get('cf-connecting-ip') - if (cfConnectingIP && isValidIP(cfConnectingIP)) { - this.logger.debug('Extracted client IP from CF-Connecting-IP', { - clientIP: cfConnectingIP, - remoteIP, - }) - return cfConnectingIP + // Prefer Cloudflare-signed header only when explicitly enabled (V-18). + if (this.config.trustCloudflare) { + const cfConnectingIP = headers.get('cf-connecting-ip') + if (cfConnectingIP && isValidIP(cfConnectingIP)) { + this.logger.debug('Extracted client IP from CF-Connecting-IP', { + clientIP: cfConnectingIP, + remoteIP, + }) + return cfConnectingIP + } } - // X-Real-IP / X-Client-IP are only trustworthy when the immediate proxy - // has been validated above. - const xRealIP = headers.get('x-real-ip') - if (xRealIP && isValidIP(xRealIP)) { - this.logger.debug('Extracted client IP from X-Real-IP', { - clientIP: xRealIP, - remoteIP, - }) - return xRealIP - } + // X-Real-IP / X-Client-IP are only trustworthy when explicitly enabled + // and the immediate proxy has been validated above (V-18). + if (this.config.trustXRealIP) { + const xRealIP = headers.get('x-real-ip') + if (xRealIP && isValidIP(xRealIP)) { + this.logger.debug('Extracted client IP from X-Real-IP', { + clientIP: xRealIP, + remoteIP, + }) + return xRealIP + } - const xClientIP = headers.get('x-client-ip') - if (xClientIP && isValidIP(xClientIP)) { - this.logger.debug('Extracted client IP from X-Client-IP', { - clientIP: xClientIP, - remoteIP, - }) - return xClientIP + const xClientIP = headers.get('x-client-ip') + if (xClientIP && isValidIP(xClientIP)) { + this.logger.debug('Extracted client IP from X-Client-IP', { + clientIP: xClientIP, + remoteIP, + }) + return xClientIP + } } // No valid forwarded header found, use direct connection IP diff --git a/src/security/utils.ts b/src/security/utils.ts index 0fd73db..a962bdf 100644 --- a/src/security/utils.ts +++ b/src/security/utils.ts @@ -66,14 +66,13 @@ export function generateSecureRandomWithEntropy(entropyBits: number): string { */ const NON_CANONICAL_ENCODING_PATTERNS = [ // 2-byte overlong forms (U+0000 - U+007F encoded as 2 bytes) - /%[cC][01][0-9a-fA-F]%[89aAbB][0-9a-fA-F]/, + /%[cC][01]%[89aAbB][0-9a-fA-F]/, // 3-byte overlong forms (U+0000 - U+07FF encoded as 3 bytes) /%[eE]0%[89aAbB][0-9a-fA-F]%[89aAbB][0-9a-fA-F]/, // 4-byte overlong forms (U+0000 - U+FFFF encoded as 4 bytes) /%[fF]0%[89aAbB][0-9a-fA-F]%[89aAbB][0-9a-fA-F]%[89aAbB][0-9a-fA-F]/, - // Invalid leading bytes (fe/ff) and lone continuation bytes + // Invalid leading bytes (fe/ff) /%[fF][eEfF]/, - /%[89aAbB][0-9a-fA-F](?!%)/, ] /** @@ -108,6 +107,13 @@ export function recursiveDecodeURIComponent(input: string): string { } iterations++ } + + // If percent escapes remain after the decode budget, a downstream that decodes + // more aggressively may interpret them differently (e.g. 6+ layers of %25). + if (/%[0-9a-fA-F]{2}/.test(decoded)) { + throw new Error('Path contains incompletely decoded percent encoding') + } + return decoded } @@ -179,6 +185,23 @@ export function isValidHeaderValue(value: string): boolean { return /^[\t\x20-\x7E\x80-\xFF]*$/.test(value) } +/** + * Boundary-aware exclude-path matching. + * + * `/health` must not match `/healthcheck`, and `/api/public` must not match + * `/api/publicity/admin`. A trailing slash is normalised before comparison. + */ +export function matchesExcludedPath( + pathname: string, + excludePaths: string[], +): boolean { + return excludePaths.some((excluded) => { + const ex = excluded.endsWith('/') ? excluded.slice(0, -1) : excluded + if (ex === '') return false + return pathname === ex || pathname.startsWith(ex + '/') + }) +} + /** * Validates that a string contains only allowed characters */ @@ -405,6 +428,7 @@ export function redactSensitiveData( 'token', 'key', 'authorization', + 'cookie', ], ): any { if (typeof obj !== 'object' || obj === null) { diff --git a/src/security/validation-middleware.ts b/src/security/validation-middleware.ts index 24fb48f..99c29ab 100644 --- a/src/security/validation-middleware.ts +++ b/src/security/validation-middleware.ts @@ -121,17 +121,29 @@ export function createValidationMiddleware( // Handle unexpected errors during validation defaultLogger.error('Validation middleware error', error as Error) + const message = + error instanceof Error + ? error.message + : 'An error occurred during request validation' + + const isClientEncodingError = + message.includes('Non-canonical percent encoding') || + message.includes('incompletely decoded percent encoding') + return new Response( JSON.stringify({ error: { code: 'VALIDATION_ERROR', - message: 'An error occurred during request validation', + message: isClientEncodingError + ? 'Invalid request encoding' + : 'An error occurred during request validation', requestId, timestamp: Date.now(), + ...(isClientEncodingError ? { details: [message] } : {}), }, }), { - status: 500, + status: isClientEncodingError ? 400 : 500, headers: { 'Content-Type': 'application/json', 'X-Request-ID': requestId, diff --git a/test/e2e/hooks.test.ts b/test/e2e/hooks.test.ts index b721b31..173f853 100644 --- a/test/e2e/hooks.test.ts +++ b/test/e2e/hooks.test.ts @@ -631,38 +631,7 @@ describe('Hooks E2E Tests', () => { }, 20000) test('should handle async fallback response generation', async () => { - // Create a new failing server for this test - const asyncFailingPort = Math.floor(Math.random() * 10000) + 56000 - const asyncFailingServer = Bun.serve({ - port: asyncFailingPort, - fetch: async (req) => { - const url = new URL(req.url) - if (url.pathname === '/error') { - return new Response('Server error', { status: 500 }) - } - return new Response('OK', { status: 200 }) - }, - } as Parameters[0]) - - // Wait for the failing server to be ready - let failingServerReady = false - for (let i = 0; i < 20; i++) { - try { - const healthCheck = await fetch(`http://localhost:${asyncFailingPort}/`) - if (healthCheck.status === 200) { - failingServerReady = true - break - } - } catch { - await new Promise((resolve) => setTimeout(resolve, 50)) - } - } - if (!failingServerReady) { - asyncFailingServer.stop() - throw new Error('Failing server failed to start') - } - - // Create a new gateway with async onError hook + // Reuse the shared failing server so we do not re-flake on CI server startup const asyncGatewayPort = Math.floor(Math.random() * 10000) + 53000 const asyncGateway = new BunGateway({ server: { @@ -674,8 +643,8 @@ describe('Hooks E2E Tests', () => { const asyncRouteConfig: RouteConfig = { pattern: '/api/async-fallback/*', - target: `http://localhost:${asyncFailingPort}`, - timeout: 1000, + target: `http://localhost:${failingPort}`, + timeout: 5000, proxy: { pathRewrite: { '^/api/async-fallback': '', @@ -686,7 +655,7 @@ describe('Hooks E2E Tests', () => { asyncErrorCalls.push({ req, error }) // Simulate async operation (e.g., logging, fetching from cache, etc.) - await new Promise((resolve) => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 10)) // Generate dynamic fallback based on the request const url = new URL(req.url) @@ -712,12 +681,9 @@ describe('Hooks E2E Tests', () => { asyncGateway.addRoute(asyncRouteConfig) const asyncServer = await asyncGateway.listen(asyncGatewayPort) - // Wait for the gateway server to be ready - await new Promise((resolve) => setTimeout(resolve, 1000)) - // Wait for the gateway server to be ready with proper health check let gatewayReady = false - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 100; i++) { try { const healthCheck = await fetch( `http://localhost:${asyncGatewayPort}/api/async-fallback/`, @@ -731,7 +697,6 @@ describe('Hooks E2E Tests', () => { } if (!gatewayReady) { asyncServer.stop() - asyncFailingServer.stop() throw new Error('Gateway server failed to start') } @@ -758,7 +723,6 @@ describe('Hooks E2E Tests', () => { ) } finally { asyncServer.stop() - asyncFailingServer.stop() } }, 30000) }) diff --git a/test/e2e/security-middleware-order.test.ts b/test/e2e/security-middleware-order.test.ts index 7ba126c..e34579f 100644 --- a/test/e2e/security-middleware-order.test.ts +++ b/test/e2e/security-middleware-order.test.ts @@ -5,13 +5,13 @@ * before route-specific custom middleware for proper security enforcement. */ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'bun:test' import { BunGateway } from '../../src/gateway/gateway' import type { RequestHandler } from '../../src/interfaces/middleware' describe('Security Middleware Order', () => { let backendServer: any - let gateway: BunGateway + let gateway: BunGateway | undefined beforeAll(async () => { // Start a simple backend server @@ -25,6 +25,13 @@ describe('Security Middleware Order', () => { }) }) + afterEach(async () => { + if (gateway) { + await gateway.close() + gateway = undefined + } + }) + afterAll(async () => { if (gateway) await gateway.close() if (backendServer) backendServer.stop() @@ -54,7 +61,7 @@ describe('Security Middleware Order', () => { pattern: '/api/protected/*', target: 'http://localhost:9010', auth: { - secret: 'test-secret', + secret: 'test-secret-at-least-32-bytes-long-for-hs256!', optional: false, }, middlewares: [customMiddleware], @@ -77,7 +84,7 @@ describe('Security Middleware Order', () => { pattern: '/api/optional/*', target: 'http://localhost:9010', auth: { - secret: 'test-secret', + secret: 'test-secret-at-least-32-bytes-long-for-hs256!', optional: true, // Allow requests without auth }, middlewares: [customMiddleware], @@ -161,7 +168,7 @@ describe('Security Middleware Order', () => { pattern: '/api/secure/*', target: 'http://localhost:9010', auth: { - secret: 'test-secret', + secret: 'test-secret-at-least-32-bytes-long-for-hs256!', optional: false, }, rateLimit: { @@ -185,7 +192,7 @@ describe('Security Middleware Order', () => { pattern: '/api/secure-optional/*', target: 'http://localhost:9010', auth: { - secret: 'test-secret', + secret: 'test-secret-at-least-32-bytes-long-for-hs256!', optional: true, // Allow requests without auth }, rateLimit: { diff --git a/test/gateway/gateway-advanced-routing.test.ts b/test/gateway/gateway-advanced-routing.test.ts index 3155009..d3081b5 100644 --- a/test/gateway/gateway-advanced-routing.test.ts +++ b/test/gateway/gateway-advanced-routing.test.ts @@ -50,7 +50,7 @@ describe('BunGateway Advanced Routing', () => { }) }, auth: { - secret: 'test-secret', + secret: 'test-secret-at-least-32-bytes-long-for-hs256!', optional: true, // Make it optional for testing }, } diff --git a/test/gateway/gateway-rate-limiting.test.ts b/test/gateway/gateway-rate-limiting.test.ts index d48a22b..95938e0 100644 --- a/test/gateway/gateway-rate-limiting.test.ts +++ b/test/gateway/gateway-rate-limiting.test.ts @@ -141,6 +141,50 @@ describe('BunGateway Rate Limiting (0http-bun)', () => { expect(user2Response1.status).toBe(200) }) + test('should apply global rate limiting with excludePaths', async () => { + const gateway = new BunGateway({ + rateLimit: { + windowMs: 60000, + max: 1, + excludePaths: ['/health'], + }, + }) + + gateway.addRoute({ + pattern: '/*', + handler: (req: ZeroRequest) => + Response.json({ path: new URL(req.url).pathname }), + }) + + // First request to a non-excluded path succeeds + const response1 = await gateway.fetch( + new Request('http://localhost/api/data'), + ) + expect(response1.status).toBe(200) + + // Second request to the same path is rate limited + const response2 = await gateway.fetch( + new Request('http://localhost/api/data'), + ) + expect(response2.status).toBe(429) + + // Excluded path remains available + for (let i = 0; i < 3; i++) { + const healthResponse = await gateway.fetch( + new Request('http://localhost/health'), + ) + expect(healthResponse.status).toBe(200) + } + + // Boundary: '/healthcheck' should NOT match the excluded '/health' prefix + const healthCheckResponse = await gateway.fetch( + new Request('http://localhost/healthcheck'), + ) + expect(healthCheckResponse.status).toBe(429) + + await gateway.close() + }) + test('should exclude paths from rate limiting', async () => { const route: RouteConfig = { pattern: '/api/maybe-limited/*', @@ -184,6 +228,49 @@ describe('BunGateway Rate Limiting (0http-bun)', () => { } }) + test('should use boundary-aware excludePaths for route rate limiting', async () => { + const route: RouteConfig = { + pattern: '/api/maybe-limited/*', + methods: ['GET'], + handler: (req: ZeroRequest) => { + return Response.json({ + message: 'Success', + path: new URL(req.url).pathname, + }) + }, + rateLimit: { + windowMs: 60000, + max: 1, + excludePaths: ['/api/maybe-limited/health'], + }, + } + + gateway.addRoute(route) + + // The excluded path itself is not rate limited. + const healthResponse = await gateway.fetch( + new Request('http://localhost/api/maybe-limited/health', { + method: 'GET', + }), + ) + expect(healthResponse.status).toBe(200) + + // A sibling path sharing the prefix MUST still be rate limited. + const response1 = await gateway.fetch( + new Request('http://localhost/api/maybe-limited/healthcheck', { + method: 'GET', + }), + ) + expect(response1.status).toBe(200) + + const response2 = await gateway.fetch( + new Request('http://localhost/api/maybe-limited/healthcheck', { + method: 'GET', + }), + ) + expect(response2.status).toBe(429) + }) + test('should provide rate limit context in request', async () => { const route: RouteConfig = { pattern: '/api/rate-context', diff --git a/test/gateway/gateway-security-findings.test.ts b/test/gateway/gateway-security-findings.test.ts new file mode 100644 index 0000000..01d349a --- /dev/null +++ b/test/gateway/gateway-security-findings.test.ts @@ -0,0 +1,93 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { BunGateway } from '../../src/gateway/gateway' +import type { ZeroRequest } from '../../src/interfaces/middleware' + +describe('BunGateway security findings', () => { + let gateway: BunGateway + let upstream: ReturnType + let upstreamUrl: string + let gatewayServer: ReturnType + let gatewayUrl: string + + beforeAll(async () => { + upstream = Bun.serve({ + port: 0, + fetch: (req) => { + const url = new URL(req.url) + if (url.pathname === '/echo-host') { + return Response.json({ host: req.headers.get('host') }) + } + if (url.pathname === '/redirect') { + return new Response(null, { + status: 302, + headers: { location: 'http://127.0.0.1:1/internal' }, + }) + } + return new Response('OK') + }, + }) + upstreamUrl = `http://localhost:${upstream.port}` + + gateway = new BunGateway({ + logger: undefined, + routes: [ + { + pattern: '/proxy/*', + target: upstreamUrl, + proxy: { + pathRewrite: { + '^/proxy': '', + }, + }, + }, + { + pattern: '/proxy-redirect/*', + target: upstreamUrl, + proxy: { + followRedirects: true, + pathRewrite: { + '^/proxy-redirect': '', + }, + }, + }, + { + pattern: '/error', + handler: async () => { + throw new Error('POC-SECRET-MARKER') + }, + }, + ], + }) + + gatewayServer = await gateway.listen(0) + gatewayUrl = `http://localhost:${gatewayServer.port}` + }) + + afterAll(async () => { + await gateway.close() + upstream.stop() + }) + + test('simple proxy sends upstream host, not localhost (V-11)', async () => { + const response = await fetch(`${gatewayUrl}/proxy/echo-host`) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.host).toBe(`localhost:${upstream.port}`) + }) + + test('does not follow upstream redirects by default (V-1)', async () => { + const response = await fetch(`${gatewayUrl}/proxy/redirect`, { + redirect: 'manual', + }) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('http://127.0.0.1:1/internal') + }) + + test('async handler errors are sanitized (V-9)', async () => { + const response = await fetch(`${gatewayUrl}/error`) + expect(response.status).toBe(500) + const text = await response.text() + expect(text).not.toContain('POC-SECRET-MARKER') + expect(text).toContain('Internal server error') + }) +}) diff --git a/test/gateway/gateway.test.ts b/test/gateway/gateway.test.ts index 157fc18..ed5c4ce 100644 --- a/test/gateway/gateway.test.ts +++ b/test/gateway/gateway.test.ts @@ -235,4 +235,60 @@ describe('BunGateway HTTP method helpers', () => { ) expect(res.status).toBe(204) }) + + test('should register OPTIONS route', async () => { + gateway.options('/options', async () => new Response('options-ok')) + const res = await gateway.fetch( + new Request('http://localhost/options', { method: 'OPTIONS' }), + ) + expect(await res.text()).toBe('options-ok') + }) + + test('should validate CORS config against security policy', () => { + const secureGateway = new BunGateway({ + security: { + corsValidation: { + allowWildcardWithCredentials: false, + requireHttps: true, + maxOrigins: 1, + }, + }, + }) + const validator = (secureGateway as any).validateCORSConfig.bind( + secureGateway, + ) + + // No validation configured – should not throw + const permissiveGateway = new BunGateway() + expect(() => + (permissiveGateway as any).validateCORSConfig({ + origin: '*', + credentials: true, + }), + ).not.toThrow() + + // Wildcard origin with credentials rejected + expect(() => + validator({ + origin: '*', + credentials: true, + }), + ).toThrow('wildcard origin with credentials is not allowed') + + // Non-HTTPS origins rejected when required + expect(() => + validator({ + origin: 'http://example.com', + }), + ).toThrow('non-HTTPS origins are not allowed') + + // Too many origins rejected + expect(() => + validator({ + origin: ['https://a.com', 'https://b.com'], + }), + ).toThrow('number of origins (2) exceeds maximum (1)') + + secureGateway.close().catch(() => {}) + }) }) diff --git a/test/load-balancer/load-balancer.test.ts b/test/load-balancer/load-balancer.test.ts index e222984..95eaf45 100644 --- a/test/load-balancer/load-balancer.test.ts +++ b/test/load-balancer/load-balancer.test.ts @@ -350,6 +350,53 @@ describe('HttpLoadBalancer', () => { // let's test the session handling logic by manually creating a session // This is a limitation of the current test setup }) + + test('caps sticky session map and ignores unknown cookies by default (V-3)', () => { + const config: LoadBalancerConfig = { + strategy: 'round-robin', + targets: [getTarget(0), getTarget(1)], + stickySession: { + enabled: true, + cookieName: 'lb-session', + ttl: 60000, + maxSessions: 5, + }, + } + + loadBalancer = createLoadBalancer(config) + + // Unknown cookie values must not mint new sessions. + for (let i = 0; i < 10; i++) { + const req = createMockRequestWithCookie('lb-session', `unknown-${i}`) + loadBalancer.selectTarget(req) + } + + const sessions = (loadBalancer as any).sessions + expect(sessions.size).toBe(0) + + // New requests without cookies are bounded by maxSessions. + for (let i = 0; i < 10; i++) { + loadBalancer.selectTarget(createMockRequest()) + } + expect(sessions.size).toBe(5) + }) + + test('refuses invalid sticky-session cookie names (V-21)', () => { + const balancer = createLoadBalancer({ + strategy: 'round-robin', + targets: [getTarget(0), getTarget(1)], + stickySession: { + enabled: true, + cookieName: 'lb;session', + ttl: 60000, + }, + }) + + // Cookie name validation is enforced when a Set-Cookie header is built. + expect(() => balancer.selectTarget(createMockRequest())).toThrow( + /Invalid sticky-session cookie name/, + ) + }) }) describe('Statistics', () => { @@ -2325,4 +2372,159 @@ describe('HttpLoadBalancer', () => { expect((loadBalancer as any).sessionCleanupInterval).toBeUndefined() }) }) + + describe('Security remediation coverage', () => { + test('refreshStickySession updates expiry and returns cookie', () => { + const config: LoadBalancerConfig = { + strategy: 'round-robin', + targets: [getTarget(0)], + stickySession: { + enabled: true, + cookieName: 'lb-session', + ttl: 60000, + }, + } + + loadBalancer = createLoadBalancer(config) + const sessions = (loadBalancer as any).sessions as Map + sessions.set('session-1', { + targetUrl: getTarget(0).url, + createdAt: Date.now(), + expiresAt: Date.now() - 1000, + }) + + const refresh = (loadBalancer as any).refreshStickySession.bind( + loadBalancer, + ) + const cookie = refresh('session-1') + expect(cookie).toContain('lb-session=session-1') + expect(sessions.get('session-1')!.expiresAt).toBeGreaterThan(Date.now()) + + // Unknown session returns undefined + expect(refresh('missing')).toBeUndefined() + }) + + test('readBoundedResponse reads at most maxBytes', async () => { + const config: LoadBalancerConfig = { + strategy: 'round-robin', + targets: [getTarget(0)], + } + + loadBalancer = createLoadBalancer(config) + const reader = (loadBalancer as any).readBoundedResponse.bind( + loadBalancer, + ) + + const response = new Response('hello world') + const text = await reader(response, 5) + expect(text).toBe('hello') + + const emptyResponse = new Response(null) + expect(await reader(emptyResponse, 100)).toBe('') + }) + + test('health check allowedHosts supports CIDR matching', async () => { + const fetchSpy = createFetchSpy(async () => { + return new Response('OK', { status: 200 }) + }) + + const config: LoadBalancerConfig = { + strategy: 'round-robin', + targets: [ + { + url: 'http://192.168.1.50:8080', + healthy: true, + weight: 1, + connections: 0, + averageResponseTime: 100, + lastHealthCheck: Date.now(), + }, + ], + healthCheck: { + enabled: true, + interval: 1000, + timeout: 1000, + path: '/health', + allowedHosts: ['192.168.1.0/24'], + }, + } + + loadBalancer = createLoadBalancer(config) + + // Wait for at least one health check cycle + await new Promise((resolve) => setTimeout(resolve, 1500)) + expect(loadBalancer.getHealthyTargets().length).toBe(1) + fetchSpy.mockRestore() + }) + + test('health check allowedHosts rejects non-matching hosts', async () => { + const fetchSpy = createFetchSpy(async () => { + return new Response('OK', { status: 200 }) + }) + + const config: LoadBalancerConfig = { + strategy: 'round-robin', + targets: [ + { + url: 'http://10.0.0.5:8080', + healthy: true, + weight: 1, + connections: 0, + averageResponseTime: 100, + lastHealthCheck: Date.now(), + }, + ], + healthCheck: { + enabled: true, + interval: 1000, + timeout: 1000, + path: '/health', + allowedHosts: ['192.168.1.0/24'], + }, + } + + loadBalancer = createLoadBalancer(config) + + // Allow enough health-check cycles to cross the default failure threshold. + await new Promise((resolve) => setTimeout(resolve, 4500)) + expect(loadBalancer.getHealthyTargets().length).toBe(0) + fetchSpy.mockRestore() + }) + + test('startSessionCleanup interval callback cleans expired sessions', () => { + const originalSetInterval = globalThis.setInterval + let capturedCallback: (() => void) | undefined + + globalThis.setInterval = ((callback: any) => { + capturedCallback = callback + return 123 as any + }) as any + + try { + const config: LoadBalancerConfig = { + strategy: 'round-robin', + targets: [getTarget(0)], + stickySession: { + enabled: true, + cookieName: 'lb-session', + ttl: 60000, + }, + } + + loadBalancer = createLoadBalancer(config) + const sessions = (loadBalancer as any).sessions as Map + sessions.set('expired', { + targetUrl: getTarget(0).url, + createdAt: Date.now(), + expiresAt: Date.now() - 1000, + }) + + expect(capturedCallback).toBeDefined() + capturedCallback!() + expect(sessions.has('expired')).toBe(false) + } finally { + globalThis.setInterval = originalSetInterval + } + }) + }) }) diff --git a/test/proxy/gateway-proxy.test.ts b/test/proxy/gateway-proxy.test.ts index fe1c829..600614e 100644 --- a/test/proxy/gateway-proxy.test.ts +++ b/test/proxy/gateway-proxy.test.ts @@ -5,7 +5,11 @@ import { describe, test, expect, beforeEach, spyOn } from 'bun:test' import { GatewayProxy, createGatewayProxy, + resolveTargetUrl, + matchesHostname, + isRedirectAllowed, } from '../../src/proxy/gateway-proxy.ts' +import { FetchProxy } from 'fetch-gate/lib/proxy' import type { ProxyOptions, ProxyRequestOptions, @@ -69,3 +73,200 @@ describe('createGatewayProxy', () => { expect(instance).toHaveProperty('clearURLCache') }) }) + +describe('resolveTargetUrl', () => { + test('returns source URL when it contains scheme', () => { + const url = resolveTargetUrl('http://example.com/path') + expect(url.href).toBe('http://example.com/path') + }) + + test('resolves against base URL', () => { + const url = resolveTargetUrl('/path', 'http://example.com') + expect(url.href).toBe('http://example.com/path') + }) + + test('falls back to localhost for malformed input', () => { + const url = resolveTargetUrl('path', 'not-a-base') + expect(url.href).toBe('http://localhost/path') + }) +}) + +describe('matchesHostname', () => { + test('matches exact hostnames case-insensitively', () => { + expect(matchesHostname('Example.COM', 'example.com')).toBe(true) + expect(matchesHostname('example.com', 'other.com')).toBe(false) + }) + + test('matches wildcard patterns', () => { + expect(matchesHostname('api.example.com', '*.example.com')).toBe(true) + expect(matchesHostname('example.com', '*.example.com')).toBe(true) + expect(matchesHostname('api.other.com', '*.example.com')).toBe(false) + }) +}) + +describe('isRedirectAllowed', () => { + const original = new URL('http://example.com/api') + + test('allows same-origin redirects by default', () => { + expect( + isRedirectAllowed(new URL('http://example.com/other'), original, {}), + ).toBe(true) + expect( + isRedirectAllowed(new URL('http://other.com/other'), original, {}), + ).toBe(false) + }) + + test('allows allow-listed hostnames', () => { + expect( + isRedirectAllowed(new URL('http://api.example.com/x'), original, { + redirectAllowlist: ['*.example.com'], + }), + ).toBe(true) + }) + + test('rejects same-origin redirects when disabled', () => { + expect( + isRedirectAllowed(new URL('http://example.com/other'), original, { + redirectSameOrigin: false, + }), + ).toBe(false) + }) +}) + +describe('GatewayProxy redirect handling', () => { + let handler: GatewayProxy + + beforeEach(() => { + handler = new GatewayProxy({ + followRedirects: true, + base: 'http://original.example.com', + }) + }) + + test('follows same-origin GET redirect', async () => { + const spy = spyOn(FetchProxy.prototype, 'proxy') + spy + .mockImplementationOnce( + async () => + new Response(null, { + status: 302, + headers: { location: 'http://original.example.com/followed' }, + }), + ) + .mockImplementationOnce( + async () => new Response('followed', { status: 200 }), + ) + + const req = new Request('http://original.example.com/api') + const res = await handler.proxy( + req as any, + 'http://original.example.com/api', + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('followed') + expect(spy).toHaveBeenCalledTimes(2) + + spy.mockRestore() + }) + + test('does not follow cross-origin redirect by default', async () => { + const spy = spyOn(FetchProxy.prototype, 'proxy') + spy.mockImplementationOnce( + async () => + new Response(null, { + status: 302, + headers: { location: 'http://evil.com/secret' }, + }), + ) + + const req = new Request('http://original.example.com/api') + const res = await handler.proxy( + req as any, + 'http://original.example.com/api', + ) + + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('http://evil.com/secret') + + spy.mockRestore() + }) + + test('follows redirect when hostname is allow-listed', async () => { + const spy = spyOn(FetchProxy.prototype, 'proxy') + spy + .mockImplementationOnce( + async () => + new Response(null, { + status: 302, + headers: { location: 'http://api.example.com/followed' }, + }), + ) + .mockImplementationOnce( + async () => new Response('followed', { status: 200 }), + ) + + const proxy = new GatewayProxy({ + followRedirects: true, + base: 'http://original.example.com', + redirectAllowlist: ['*.example.com'], + }) + + const req = new Request('http://original.example.com/api') + const res = await proxy.proxy(req as any, 'http://original.example.com/api') + + expect(res.status).toBe(200) + expect(await res.text()).toBe('followed') + + spy.mockRestore() + }) + + test('does not follow non-GET/HEAD redirects', async () => { + const spy = spyOn(FetchProxy.prototype, 'proxy') + spy.mockImplementationOnce( + async () => + new Response(null, { + status: 302, + headers: { location: 'http://original.example.com/created' }, + }), + ) + + const req = new Request('http://original.example.com/api', { + method: 'POST', + }) + const res = await handler.proxy( + req as any, + 'http://original.example.com/api', + ) + + expect(res.status).toBe(302) + + spy.mockRestore() + }) + + test('stops following after maxRedirects', async () => { + const spy = spyOn(FetchProxy.prototype, 'proxy') + let calls = 0 + spy.mockImplementation(async () => { + calls++ + return new Response(null, { + status: 302, + headers: { location: `http://original.example.com/step-${calls}` }, + }) + }) + + const proxy = new GatewayProxy({ + followRedirects: true, + maxRedirects: 2, + base: 'http://original.example.com', + }) + + const req = new Request('http://original.example.com/api') + const res = await proxy.proxy(req as any, 'http://original.example.com/api') + + expect(res.status).toBe(302) + expect(calls).toBe(3) // initial + 2 redirects + + spy.mockRestore() + }) +}) diff --git a/test/security/error-handler-middleware.test.ts b/test/security/error-handler-middleware.test.ts index 0061817..29c18fd 100644 --- a/test/security/error-handler-middleware.test.ts +++ b/test/security/error-handler-middleware.test.ts @@ -178,6 +178,24 @@ describe('ErrorHandlerMiddleware', () => { expect(body.error.message).not.toContain('8080') }) + test('should sanitize backend URL extracted from error.url', async () => { + const middleware = createProductionErrorHandler() + const req = new Request('http://localhost/test') as any + + const next = async () => { + const error = new Error('Connection failed') as any + error.backend = true + error.url = 'http://internal-api:3000/secret' + throw error + } + + const response = await middleware(req, next) + const body: any = await response!.json() + + expect(body.error.message).not.toContain('internal-api') + expect(body.error.message).not.toContain('3000') + }) + test('should detect ECONNREFUSED errors', async () => { const middleware = createErrorHandlerMiddleware({ production: true, @@ -341,7 +359,11 @@ describe('ErrorHandlerMiddleware', () => { }) test('should include details in development mode', async () => { - const middleware = createDevelopmentErrorHandler() + const middleware = createErrorHandlerMiddleware({ + production: false, + includeStackTrace: true, + logErrors: false, + } as any) const req = new Request('http://localhost/test') as any const next = async () => { diff --git a/test/security/error-handler.test.ts b/test/security/error-handler.test.ts index 60cad5f..e73520c 100644 --- a/test/security/error-handler.test.ts +++ b/test/security/error-handler.test.ts @@ -157,12 +157,16 @@ describe('SecureErrorHandler', () => { describe('error logging', () => { test('should log errors when enabled', () => { const logs: any[] = [] - const originalError = console.error - console.error = (...args: any[]) => logs.push(args) + const logger = { + error: (entry: any) => logs.push(entry), + warn: (entry: any) => logs.push(entry), + info: (entry: any) => logs.push(entry), + } const handler = new SecureErrorHandler({ logErrors: true, production: false, + logger, }) const error = new Error('Test error') const req = new Request('http://localhost/test') @@ -170,34 +174,37 @@ describe('SecureErrorHandler', () => { handler.handleError(error, req) expect(logs.length).toBeGreaterThan(0) - - console.error = originalError }) test('should not log errors when disabled', () => { const logs: any[] = [] - const originalError = console.error - console.error = (...args: any[]) => logs.push(args) + const logger = { + error: (entry: any) => logs.push(entry), + warn: (entry: any) => logs.push(entry), + info: (entry: any) => logs.push(entry), + } - const handler = new SecureErrorHandler({ logErrors: false }) + const handler = new SecureErrorHandler({ logErrors: false, logger }) const error = new Error('Test error') const req = new Request('http://localhost/test') handler.handleError(error, req) expect(logs.length).toBe(0) - - console.error = originalError }) test('should include request context in logs', () => { const logs: any[] = [] - const originalError = console.error - console.error = (...args: any[]) => logs.push(args) + const logger = { + error: (entry: any) => logs.push(entry), + warn: (entry: any) => logs.push(entry), + info: (entry: any) => logs.push(entry), + } const handler = new SecureErrorHandler({ logErrors: true, production: false, + logger, }) const error = new Error('Test error') const req = new Request('http://localhost/test?param=value', { @@ -208,21 +215,23 @@ describe('SecureErrorHandler', () => { handler.handleError(error, req) expect(logs.length).toBeGreaterThan(0) - const logEntry = JSON.parse(logs[0][1]) + const logEntry = logs[0] expect(logEntry.request.method).toBe('POST') expect(logEntry.request.url).toContain('/test') - - console.error = originalError }) test('should redact sensitive headers in production logs', () => { const logs: any[] = [] - const originalError = console.error - console.error = (...args: any[]) => logs.push(args) + const logger = { + error: (entry: any) => logs.push(entry), + warn: (entry: any) => logs.push(entry), + info: (entry: any) => logs.push(entry), + } const handler = new SecureErrorHandler({ logErrors: true, production: true, + logger, }) const error = new Error('Test error') const req = new Request('http://localhost/test', { @@ -235,11 +244,9 @@ describe('SecureErrorHandler', () => { handler.handleError(error, req) expect(logs.length).toBeGreaterThan(0) - const logEntry = JSON.parse(logs[0][1]) + const logEntry = logs[0] expect(logEntry.request.headers.authorization).toBe('[REDACTED]') expect(logEntry.request.headers['x-api-key']).toBe('[REDACTED]') - - console.error = originalError }) }) diff --git a/test/security/http-redirect.test.ts b/test/security/http-redirect.test.ts index 0433dab..011ca7f 100644 --- a/test/security/http-redirect.test.ts +++ b/test/security/http-redirect.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, afterEach } from 'bun:test' import { createHTTPRedirectServer, HTTPRedirectManager, + matchesAllowedHost, } from '../../src/security/http-redirect' import { BunGateLogger } from '../../src/logger/pino-logger' import type { Server } from 'bun' @@ -9,8 +10,8 @@ import type { Server } from 'bun' describe('HTTP Redirect', () => { let servers: Server[] = [] - afterEach(() => { - servers.forEach((server) => server.stop()) + afterEach(async () => { + await Promise.all(servers.map((server) => server.stop())) servers = [] }) @@ -38,6 +39,7 @@ describe('HTTP Redirect', () => { const server = createHTTPRedirectServer({ port: httpPort, httpsPort, + hostname: 'localhost', }) servers.push(server) @@ -52,6 +54,7 @@ describe('HTTP Redirect', () => { const server = createHTTPRedirectServer({ port: httpPort, httpsPort, + hostname: 'localhost', }) servers.push(server) @@ -78,6 +81,7 @@ describe('HTTP Redirect', () => { const server = createHTTPRedirectServer({ port: httpPort, httpsPort, + hostname: 'localhost', }) servers.push(server) @@ -103,6 +107,7 @@ describe('HTTP Redirect', () => { const server = createHTTPRedirectServer({ port: httpPort, httpsPort, + hostname: 'localhost', }) servers.push(server) @@ -125,6 +130,7 @@ describe('HTTP Redirect', () => { const server = createHTTPRedirectServer({ port: httpPort, httpsPort, + hostname: 'localhost', }) servers.push(server) @@ -145,7 +151,8 @@ describe('HTTP Redirect', () => { const server = createHTTPRedirectServer({ port: httpPort, httpsPort, - // No custom hostname - should use request hostname + // No custom hostname - rely on an explicit allowlist for the request host. + allowHosts: ['localhost'], }) servers.push(server) @@ -171,6 +178,7 @@ describe('HTTP Redirect', () => { const server = createHTTPRedirectServer({ port: httpPort, httpsPort, + hostname: 'localhost', logger, }) servers.push(server) @@ -237,4 +245,40 @@ describe('HTTP Redirect', () => { expect(() => manager.stop()).not.toThrow() }) }) + + describe('matchesAllowedHost', () => { + test('matches exact hostnames case-insensitively', () => { + expect(matchesAllowedHost('Example.COM', 'example.com')).toBe(true) + expect(matchesAllowedHost('example.com', 'other.com')).toBe(false) + }) + + test('matches wildcard patterns', () => { + expect(matchesAllowedHost('api.example.com', '*.example.com')).toBe(true) + expect(matchesAllowedHost('example.com', '*.example.com')).toBe(true) + expect(matchesAllowedHost('api.other.com', '*.example.com')).toBe(false) + }) + }) + + describe('wildcard allowHosts', () => { + test('should redirect when request host matches wildcard allowlist', async () => { + const httpPort = await getAvailablePort() + const httpsPort = 8443 + + const server = createHTTPRedirectServer({ + port: httpPort, + httpsPort, + // localhost:port will not match *.example.com, so also allow localhost exactly. + allowHosts: ['*.example.com', 'localhost'], + }) + servers.push(server) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const response = await fetch(`http://localhost:${httpPort}/test`, { + redirect: 'manual', + }) + + expect(response.status).toBe(301) + }) + }) }) diff --git a/test/security/jwt-auth.test.ts b/test/security/jwt-auth.test.ts new file mode 100644 index 0000000..e2ef2de --- /dev/null +++ b/test/security/jwt-auth.test.ts @@ -0,0 +1,606 @@ +import { describe, test, expect } from 'bun:test' +import { + SignJWT, + importSPKI, + exportSPKI, + exportPKCS8, + exportJWK, + generateKeyPair, + CompactSign, +} from 'jose' +import { createJWTAuth } from '../../src/security/jwt-auth' +import type { ZeroRequest } from '../../src/interfaces/middleware' + +function zeroRequest( + url: string, + headers?: Record, +): ZeroRequest { + const req = new Request(url, { headers }) as ZeroRequest + req.ctx = {} + return req +} + +describe('createJWTAuth hardened middleware', () => { + test('rejects a token without an exp claim', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret }) + + const token = await new SignJWT({ sub: 'attacker' }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode(secret)) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + const body = await response.json() + expect(body.error).toMatch(/expired|exp/i) + }) + + test('accepts a token with an exp claim', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret }) + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(secret)) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('rejects HS256 token signed with a PEM public key (algorithm confusion)', async () => { + const { publicKey } = await generateKeyPair('RS256') + const publicKeyPEM = await exportSPKI(publicKey) + + // Attacker forges an HS256 token using the public key string as secret. + const forgedToken = await new SignJWT({ sub: 'attacker', role: 'admin' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(publicKeyPEM)) + + // Operator misconfigures the public key PEM as the HMAC secret. + const middleware = createJWTAuth({ secret: publicKeyPEM }) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${forgedToken}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) + + test('verifies an RS256 token with the matching public key', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicKeyPEM = await exportSPKI(publicKey) + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime('1h') + .sign(privateKey) + + const middleware = createJWTAuth({ secret: publicKeyPEM }) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('rejects a short HMAC secret', () => { + expect(() => createJWTAuth({ secret: 'short' })).toThrow( + /secret too short/i, + ) + }) + + test('rejects API key when apiKeys is neither array nor function', async () => { + const middleware = createJWTAuth({ apiKeys: 'not-a-valid-config' as any }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'key1', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) + + test('enforces audience claim when configured', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret, audience: 'expected-audience' }) + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .setAudience('wrong-audience') + .sign(new TextEncoder().encode(secret)) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) + + test('uses boundary-aware excludePaths matching', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ + secret, + excludePaths: ['/api/public'], + }) + + const publicReq = zeroRequest('http://localhost/api/public') + const publicResponse = await middleware(publicReq, () => new Response('OK')) + expect(publicResponse.status).toBe(200) + + const siblingReq = zeroRequest('http://localhost/api/publicity/admin') + const siblingResponse = await middleware( + siblingReq, + () => new Response('OK'), + ) + expect(siblingResponse.status).toBe(401) + }) + + test('rejects algorithm "none"', () => { + expect(() => + createJWTAuth({ secret: 'x'.repeat(32), algorithms: ['none'] }), + ).toThrow(/none.*not allowed/i) + }) + + test('throws when no auth mechanism is configured', () => { + expect(() => createJWTAuth({})).toThrow( + /requires either secret, jwksUri, jwks, or apiKeys/i, + ) + }) + + test('rejects a token with invalid issuer', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret, issuer: 'expected-issuer' }) + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .setIssuer('wrong-issuer') + .sign(new TextEncoder().encode(secret)) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + const body = await response.json() + expect(body.error).toMatch(/issuer/i) + }) + + test('verifies a token using a custom token extractor', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ + secret, + getToken: (req) => req.headers.get('x-custom-token') || undefined, + }) + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(secret)) + + const req = zeroRequest('http://localhost/api/test', { + 'x-custom-token': token, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('extracts token from custom header and query parameter', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(secret)) + + const headerMiddleware = createJWTAuth({ + secret, + tokenHeader: 'x-api-token', + }) + const headerReq = zeroRequest('http://localhost/api/test', { + 'x-api-token': token, + }) + expect( + (await headerMiddleware(headerReq, () => new Response('OK'))).status, + ).toBe(200) + + const queryMiddleware = createJWTAuth({ + secret, + tokenQuery: 'token', + }) + const queryReq = zeroRequest( + `http://localhost/api/test?token=${encodeURIComponent(token)}`, + ) + expect( + (await queryMiddleware(queryReq, () => new Response('OK'))).status, + ).toBe(200) + }) + + test('rejects malformed authorization header', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret }) + + const req1 = zeroRequest('http://localhost/api/test', { + authorization: 'token', + }) + expect((await middleware(req1, () => new Response('OK'))).status).toBe(401) + + const req2 = zeroRequest('http://localhost/api/test', { + authorization: 'Basic dXNlcjpwYXNz', + }) + expect((await middleware(req2, () => new Response('OK'))).status).toBe(401) + }) + + test('validates API keys from array, function, and validator', async () => { + const middlewareArray = createJWTAuth({ apiKeys: ['key1', 'key2'] }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'key1', + }) + expect((await middlewareArray(req, () => new Response('OK'))).status).toBe( + 200, + ) + + const middlewareFn = createJWTAuth({ + apiKeys: async (key) => key === 'key1', + }) + expect((await middlewareFn(req, () => new Response('OK'))).status).toBe(200) + + const middlewareValidator = createJWTAuth({ + apiKeyValidator: (key) => key === 'key1', + }) + expect( + (await middlewareValidator(req, () => new Response('OK'))).status, + ).toBe(200) + + const invalidReq = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'wrong', + }) + expect( + (await middlewareArray(invalidReq, () => new Response('OK'))).status, + ).toBe(401) + + // apiKeys configured but request has no API key header at all + const missingKeyReq = zeroRequest('http://localhost/api/test') + expect( + (await middlewareArray(missingKeyReq, () => new Response('OK'))).status, + ).toBe(401) + }) + + test('calls one-arg apiKeyValidator when length is 1', async () => { + const middleware = createJWTAuth({ + apiKeyValidator: (key: string) => key === 'key1', + }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'key1', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('calls two-arg apiKeyValidator when length is 2', async () => { + const middleware = createJWTAuth({ + apiKeyValidator: (key: string, req: ZeroRequest) => + key === 'key1' && new URL(req.url).pathname === '/api/test', + }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'key1', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('returns custom unauthorized response', async () => { + const middleware = createJWTAuth({ + apiKeys: ['key1'], + unauthorizedResponse: new Response('custom', { status: 403 }), + }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'wrong', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(403) + expect(await response.text()).toBe('custom') + }) + + test('uses custom unauthorized response function returning object', async () => { + const middleware = createJWTAuth({ + apiKeys: ['key1'], + unauthorizedResponse: (error) => ({ + body: { message: error.message }, + status: 418, + headers: { 'x-custom': 'yes' }, + }), + }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'wrong', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(418) + expect(response.headers.get('x-custom')).toBe('yes') + expect(await response.json()).toEqual({ message: 'Invalid API key' }) + }) + + test('custom unauthorized response object serializes non-string body', async () => { + const middleware = createJWTAuth({ + apiKeys: ['key1'], + unauthorizedResponse: () => ({ + body: { detail: 'nope' }, + status: 403, + }), + }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'wrong', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ detail: 'nope' }) + }) + + test('falls back to default response when unauthorizedResponse returns primitive', async () => { + const middleware = createJWTAuth({ + apiKeys: ['key1'], + unauthorizedResponse: () => 'just-a-string' as any, + }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'wrong', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) + + test('falls back to default response when unauthorizedResponse throws', async () => { + const middleware = createJWTAuth({ + apiKeys: ['key1'], + unauthorizedResponse: () => { + throw new Error('boom') + }, + }) + const req = zeroRequest('http://localhost/api/test', { + 'x-api-key': 'wrong', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) + + test('invokes onError handler and returns its response', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ + secret, + onError: (error) => + new Response(JSON.stringify({ handled: error.message }), { + status: 400, + }), + }) + + // Trigger the catch path with a token signed by the wrong secret. + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode('wrong-secret-is-at-least-32-bytes!')) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + handled: 'signature verification failed', + }) + }) + + test('optional auth allows missing token', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret, optional: true }) + const req = zeroRequest('http://localhost/api/test') + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('optional auth still allows valid token', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret, optional: true }) + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(secret)) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('optional auth passes through when API key is missing and token invalid', async () => { + const middleware = createJWTAuth({ + apiKeys: ['key1'], + optional: true, + }) + const req = zeroRequest('http://localhost/api/test') + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('optional auth passes through in catch block with invalid token', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const middleware = createJWTAuth({ secret, optional: true }) + + const req = zeroRequest('http://localhost/api/test', { + authorization: 'Bearer not-a-valid-jwt', + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('supports inline JWKS resolver', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const spki = await exportSPKI(publicKey) + const imported = await importSPKI(spki, 'RS256') + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime('1h') + .sign(privateKey) + + const middleware = createJWTAuth({ + jwks: { getKey: async () => imported }, + }) + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('supports jwks provided as a raw key resolver function', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const spki = await exportSPKI(publicKey) + const imported = await importSPKI(spki, 'RS256') + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime('1h') + .sign(privateKey) + + const middleware = createJWTAuth({ + jwks: async () => imported as any, + }) + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('supports jwksUri remote JWK set', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const jwk = await exportJWK(publicKey) + jwk.kid = 'key-1' + jwk.use = 'sig' + + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'RS256', kid: 'key-1' }) + .setExpirationTime('1h') + .sign(privateKey) + + const server = Bun.serve({ + port: 0, + fetch: () => + new Response(JSON.stringify({ keys: [jwk] }), { + headers: { 'content-type': 'application/json' }, + }), + }) + + try { + const middleware = createJWTAuth({ + jwksUri: `http://localhost:${server.port}/.well-known/jwks.json`, + }) + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + } finally { + server.stop() + } + }) + + test('supports secret resolver function', async () => { + const secret = 'this-secret-is-at-least-32-bytes-long!' + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(secret)) + + const middleware = createJWTAuth({ + secret: async () => new TextEncoder().encode(secret), + }) + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(200) + }) + + test('rejects HS256 token signed with a PEM private key (algorithm confusion)', async () => { + const { privateKey } = await generateKeyPair('RS256', { extractable: true }) + const privateKeyPEM = await exportPKCS8(privateKey) + + // Attacker forges an HS256 token using the private key PEM string as secret. + const forgedToken = await new SignJWT({ sub: 'attacker', role: 'admin' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(privateKeyPEM)) + + // Operator misconfigures the private key PEM as the HMAC secret. + const middleware = createJWTAuth({ secret: privateKeyPEM }) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${forgedToken}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) + + test('rejects EC public key used as HMAC secret', async () => { + const { publicKey } = await generateKeyPair('ES256') + const publicKeyPEM = await exportSPKI(publicKey) + + const forgedToken = await new SignJWT({ sub: 'attacker' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(new TextEncoder().encode(publicKeyPEM)) + + const middleware = createJWTAuth({ secret: publicKeyPEM }) + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${forgedToken}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) + + test('maps JWTInvalid error to friendly message', async () => { + const secret = new TextEncoder().encode( + 'this-secret-is-at-least-32-bytes-long!', + ) + const middleware = createJWTAuth({ secret: secret }) + + // Sign a non-JSON payload; jwtVerify will throw JWTInvalid after signature check. + const jws = await new CompactSign(new TextEncoder().encode('not-json')) + .setProtectedHeader({ alg: 'HS256' }) + .sign(secret) + + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${jws}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + const body = await response.json() + expect(body.error).toMatch(/invalid token format/i) + }) + + test('maps JWKSNoMatchingKey error to friendly message', async () => { + const { privateKey } = await generateKeyPair('RS256') + const token = await new SignJWT({ sub: 'user' }) + .setProtectedHeader({ alg: 'RS256' }) + .setExpirationTime('1h') + .sign(privateKey) + + const middleware = createJWTAuth({ + jwks: { getKey: async () => null as any }, + }) + const req = zeroRequest('http://localhost/api/test', { + authorization: `Bearer ${token}`, + }) + const response = await middleware(req, () => new Response('OK')) + expect(response.status).toBe(401) + }) +}) diff --git a/test/security/jwt-key-rotation.test.ts b/test/security/jwt-key-rotation.test.ts index ffc65ff..0fb085a 100644 --- a/test/security/jwt-key-rotation.test.ts +++ b/test/security/jwt-key-rotation.test.ts @@ -30,7 +30,7 @@ describe('JWTKeyRotationManager', () => { test('should throw error if no secrets configured', () => { expect(() => { new JWTKeyRotationManager({ secrets: [] }) - }).toThrow('At least one JWT secret must be configured') + }).toThrow('At least one JWT secret or a jwksUri must be configured') }) test('should throw error if multiple primary keys configured', () => { diff --git a/test/security/session-manager.test.ts b/test/security/session-manager.test.ts index f7dc747..ad94e9b 100644 --- a/test/security/session-manager.test.ts +++ b/test/security/session-manager.test.ts @@ -254,6 +254,27 @@ describe('SessionManager', () => { }) }) + describe('rotateSessionId', () => { + test('should rotate an existing session id', () => { + const session = sessionManager.createSession('http://backend:3000', { + userId: '123', + }) + const rotated = sessionManager.rotateSessionId(session.id) + + expect(rotated).not.toBeNull() + expect(rotated!.id).not.toBe(session.id) + expect(rotated!.targetUrl).toBe(session.targetUrl) + expect(rotated!.metadata).toEqual({ userId: '123' }) + expect(sessionManager.getSession(session.id)).toBeNull() + expect(sessionManager.getSession(rotated!.id)).not.toBeNull() + }) + + test('should return null for non-existent session', () => { + const rotated = sessionManager.rotateSessionId('non-existent-id') + expect(rotated).toBeNull() + }) + }) + describe('deleteSession', () => { test('should delete an existing session', () => { const session = sessionManager.createSession('http://backend:3000') @@ -523,6 +544,35 @@ describe('SessionManager', () => { expect(manager.getSessionCount()).toBe(0) }) + test('should invoke cleanup callback from interval', () => { + const originalSetInterval = global.setInterval + let capturedCallback: (() => void) | undefined + global.setInterval = ((callback: () => void) => { + capturedCallback = callback + return 123 as unknown as ReturnType + }) as any + + try { + const manager = new SessionManager({ ttl: 1 }) + manager.createSession('http://backend:3000') + expect(capturedCallback).toBeDefined() + + // Wait for the session to expire, then invoke the captured interval callback. + return new Promise((resolve) => { + setTimeout(() => { + capturedCallback!() + expect(manager.getSessionCount()).toBe(0) + manager.destroy() + global.setInterval = originalSetInterval + resolve() + }, 50) + }) + } catch (error) { + global.setInterval = originalSetInterval + throw error + } + }) + test('should clear all sessions on destroy', () => { const manager = new SessionManager() manager.createSession('http://backend1:3000') diff --git a/test/security/tls-integration.test.ts b/test/security/tls-integration.test.ts index dbd51b9..71717e0 100644 --- a/test/security/tls-integration.test.ts +++ b/test/security/tls-integration.test.ts @@ -85,7 +85,7 @@ describe('TLS Integration with Gateway', () => { const httpPort = await getAvailablePort(httpsPort + 1) const gateway = new BunGateway({ - server: { port: httpsPort }, + server: { port: httpsPort, hostname: 'localhost' }, logger, security: { tls: { diff --git a/test/security/trusted-proxy.test.ts b/test/security/trusted-proxy.test.ts index 63d5f66..fbf59fc 100644 --- a/test/security/trusted-proxy.test.ts +++ b/test/security/trusted-proxy.test.ts @@ -332,6 +332,7 @@ describe('TrustedProxyValidator', () => { const config: TrustedProxyConfig = { enabled: true, trustedIPs: ['192.168.1.1'], + trustXRealIP: true, } const validator = new TrustedProxyValidator(config) @@ -351,6 +352,7 @@ describe('TrustedProxyValidator', () => { const config: TrustedProxyConfig = { enabled: true, trustedNetworks: ['cloudflare'], + trustCloudflare: true, } const validator = new TrustedProxyValidator(config) @@ -370,6 +372,7 @@ describe('TrustedProxyValidator', () => { const config: TrustedProxyConfig = { enabled: true, trustedIPs: ['192.168.1.1'], + trustXRealIP: true, } const validator = new TrustedProxyValidator(config) diff --git a/test/security/utils.test.ts b/test/security/utils.test.ts index 6dc1050..54199cb 100644 --- a/test/security/utils.test.ts +++ b/test/security/utils.test.ts @@ -1,4 +1,5 @@ -import { describe, test, expect } from 'bun:test' +import { describe, test, expect, spyOn } from 'bun:test' +import * as crypto from 'crypto' import { calculateEntropy, hasMinimumEntropy, @@ -7,6 +8,8 @@ import { recursiveDecodeURIComponent, sanitizePath, sanitizeHeader, + isValidHeaderName, + isValidHeaderValue, containsOnlyAllowedChars, matchesBlockedPattern, sanitizeErrorMessage, @@ -161,12 +164,11 @@ describe('recursiveDecodeURIComponent', () => { for (let i = 0; i < 6; i++) { encoded = encodeURIComponent(encoded) } - // With only 5 iterations, it will decode 5 levels but not the 6th. - const result = recursiveDecodeURIComponent(encoded) - // After 5 iterations of decoding starting from 6x-encoded '/', - // we'll have 1 layer of encoding left → '%2F', not fully to '/'. - expect(result).not.toBe('/') - expect(result).toContain('%') + // With only 5 iterations, percent escapes still remain. The hardened + // decoder rejects this to prevent bypasses against deeper-decoding backends. + expect(() => recursiveDecodeURIComponent(encoded)).toThrow( + 'incompletely decoded percent encoding', + ) }) test('is idempotent (already decoded input is stable)', () => { @@ -244,6 +246,12 @@ describe('sanitizePath', () => { test('normalizes path with multiple issues', () => { expect(sanitizePath('..//api/..%2Fusers/')).toBe('/api/users') }) + + test('rejects non-canonical encodings in path', () => { + expect(() => sanitizePath('/foo/%C0%AF')).toThrow( + /Non-canonical percent encoding detected in path/i, + ) + }) }) // ─── sanitizeHeader ─────────────────────────────────────────────────── @@ -683,6 +691,17 @@ describe('timingSafeEqual', () => { test('is case-sensitive', () => { expect(timingSafeEqual('Hello', 'hello')).toBe(false) }) + + test('returns false when crypto.timingSafeEqual throws', () => { + const mock = spyOn(crypto, 'timingSafeEqual').mockImplementation(() => { + throw new Error('unexpected') + }) + try { + expect(timingSafeEqual('same', 'same')).toBe(false) + } finally { + mock.mockRestore() + } + }) }) // ─── isValidURL ─────────────────────────────────────────────────────── @@ -764,3 +783,90 @@ describe('extractDomain', () => { expect(extractDomain('http://192.168.1.1/api')).toBe('192.168.1.1') }) }) + +// ─── header name/value validation ───────────────────────────────────── + +describe('isValidHeaderName', () => { + test('accepts valid token characters', () => { + expect(isValidHeaderName('Content-Type')).toBe(true) + expect(isValidHeaderName('X-Custom-Header')).toBe(true) + }) + + test('rejects invalid token characters', () => { + expect(isValidHeaderName('Bad Name')).toBe(false) + expect(isValidHeaderName('Bad:Name')).toBe(false) + }) +}) + +describe('isValidHeaderValue', () => { + test('accepts visible ASCII and HTAB', () => { + expect(isValidHeaderValue('value')).toBe(true) + expect(isValidHeaderValue('value\twith\ttab')).toBe(true) + }) + + test('rejects control characters', () => { + expect(isValidHeaderValue('value\r\n')).toBe(false) + expect(isValidHeaderValue('value\0')).toBe(false) + }) +}) + +// ─── non-canonical / overlong encoding detection ────────────────────── + +describe('recursiveDecodeURIComponent non-canonical encoding', () => { + test('rejects 2-byte overlong form', () => { + expect(() => recursiveDecodeURIComponent('%C0%AF')).toThrow( + /non-canonical/i, + ) + expect(() => recursiveDecodeURIComponent('%C0%80')).toThrow( + /non-canonical/i, + ) + }) + + test('rejects 3-byte overlong form', () => { + expect(() => recursiveDecodeURIComponent('%E0%80%AF')).toThrow( + /non-canonical/i, + ) + }) + + test('rejects 4-byte overlong form', () => { + expect(() => recursiveDecodeURIComponent('%F0%80%80%AF')).toThrow( + /non-canonical/i, + ) + }) + + test('rejects invalid leading bytes', () => { + expect(() => recursiveDecodeURIComponent('%FE')).toThrow(/non-canonical/i) + expect(() => recursiveDecodeURIComponent('%FF')).toThrow(/non-canonical/i) + }) +}) + +// ─── IPv6 CIDR edge cases ───────────────────────────────────────────── + +describe('isIPInCIDR IPv6 edge cases', () => { + test('returns true for matching IPv6 network', () => { + expect(isIPInCIDR('2001:db8::1', '2001:db8::/64')).toBe(true) + }) + + test('returns false for IPv6 outside network', () => { + expect(isIPInCIDR('2001:db8:1::1', '2001:db8::/64')).toBe(false) + }) + + test('returns false for out-of-range IPv6 prefix', () => { + expect(isIPInCIDR('2001:db8::1', '2001:db8::/129')).toBe(false) + }) + + test('returns false for invalid IPv6 in CIDR', () => { + expect(isIPInCIDR('2001:db8::1', 'not-an-ip/64')).toBe(false) + expect(isIPInCIDR('not-an-ip', '2001:db8::/64')).toBe(false) + }) + + test('matches on non-byte IPv6 prefix boundary', () => { + expect(isIPInCIDR('2001:db8::1', '2001:db8::/68')).toBe(true) + // Byte 8 (high nibble) differs from the network, so /68 should not match. + expect(isIPInCIDR('2001:db8:0:0:1000::', '2001:db8::/68')).toBe(false) + }) + + test('returns false for non-IP/non-IPv6 CIDR network', () => { + expect(isIPInCIDR('2001:db8::1', 'not-an-ip')).toBe(false) + }) +})