Skip to content

Commit fe98171

Browse files
authored
security: remediate exploitable vulnerabilities from 2026-07-18 audit (#8)
* security: remediate exploitable vulnerabilities from 2026-07-18 audit Fixes findings documented in sec-findings.md: High - V-1 SSRF via upstream redirect (manual redirect mode + allowlist/same-origin) - V-2 JWT algorithm confusion (algorithm derived from key type, PEM rejection) - V-3/V-19 Sticky-session unbounded map + TTL refresh (maxSessions, LRU, unknownCookiePolicy) - V-4 Open redirect on HTTP→HTTPS redirect (require hostname or allowHosts) Medium - V-5/V-13 JWT without exp + missing aud/iss (hardened jwt-auth middleware) - V-6 excludePaths prefix bypass (boundary-aware matching) - V-7/V-16 Set-Cookie/Authorization logged in plaintext (redact in logger) - V-8/V-12 non-canonical/double encoding bypass (overlong encoding rejection) - V-9 Async route errors leaking stack/paths (await router.fetch + sanitized handler) - V-10 TLS minVersion/cipherSuites ignored (map to Bun secureOptions/ciphers) - V-11 Simple proxy sending Host: localhost (build upstream URL from route.target) - V-14 Health-check body read timeout (abort signal across bounded read) - V-17 Load-balancer connection double-decrement (idempotent flag) - V-18 trustCloudflare/trustXRealIP opt-in Also fixes route-level rateLimit.excludePaths to be boundary-aware. Adds src/security/jwt-auth.ts and comprehensive tests. Updates docs/SECURITY.md, docs/API_REFERENCE.md, docs/AUTHENTICATION.md, docs/TLS_CONFIGURATION.md, docs/LOAD_BALANCING.md, docs/QUICK_START.md, docs/EXAMPLES.md, AGENTS.md, README.md and examples/security-hardened.ts. Tests: 887 pass / 0 fail. Coverage: 99.22% lines / 94.78% funcs. * test(e2e): fix flaky async fallback readiness waits on CI * test(e2e): reuse shared failing server in async fallback test
1 parent d36b6c8 commit fe98171

49 files changed

Lines changed: 2822 additions & 397 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ src/
6464
│ ├── validation-middleware.ts # Middleware wrapping InputValidator
6565
│ ├── error-handler.ts # SecureErrorHandler (production/development modes)
6666
│ ├── error-handler-middleware.ts # Middleware with circuit breaker detection
67+
│ ├── jwt-auth.ts # Hardened JWT middleware (replaces 0http-bun JWT)
6768
│ ├── jwt-key-rotation.ts # JWKS refresh, multi-secret key rotation
6869
│ ├── jwt-key-rotation-middleware.ts
6970
│ ├── security-headers.ts # HSTS, CSP, X-Frame-Options, etc.
@@ -78,6 +79,18 @@ src/
7879

7980
## Security Design Principles
8081

82+
### JWT Authentication (jwt-auth.ts)
83+
84+
The gateway uses an internal hardened JWT middleware (not the 0http-bun re-export).
85+
Key behaviors:
86+
87+
- `exp` is required on all tokens.
88+
- `audience`/`issuer` are supported as top-level `auth` options.
89+
- Allowed algorithms are derived from the key type when omitted.
90+
- PEM-like strings cannot be used as HMAC secrets (algorithm confusion prevention).
91+
- HS256 secrets must be at least 32 bytes.
92+
- `excludePaths` matching is boundary-aware.
93+
8194
### Path Validation (input-validator.ts + utils.ts)
8295

8396
**Two-pass validation** against double-encoding attacks:
@@ -100,9 +113,10 @@ Floor exempts when ALL targets are already unhealthy (genuine outage, not a casc
100113

101114
### Rate Limiting (gateway.ts)
102115

103-
Uses gateway's `getClientIP()` as the rate limit key generator, NOT `X-Forwarded-For`.
116+
Uses gateway's `getClientIP()` as the rate limit key generator, NOT raw `X-Forwarded-For`.
104117
`getClientIP()` consults `TrustedProxyValidator` when enabled, otherwise falls back to
105-
secure header priority (`cf-connecting-ip` > `x-real-ip`).
118+
secure header priority (`cf-connecting-ip` > `x-real-ip`). These proxy-specific headers are
119+
only honored when the corresponding `trustCloudflare` / `trustXRealIP` flags are enabled.
106120

107121
### Error Handling
108122

@@ -160,6 +174,15 @@ Covers: raw `..`, encoded `../`, encoded `/`, encoded `\`, null byte, double-enc
160174
7. **TypeScript strict mode.** Properties like `noUncheckedIndexedAccess` are on. Array accesses
161175
like `targets[0]` need explicit `!` assertions or length checks.
162176

177+
8. **HTTP→HTTPS redirect requires a hostname or allowlist.** The redirect server now rejects
178+
requests when neither `server.hostname` nor `tls.redirectAllowedHosts` is configured.
179+
180+
9. **HS256 JWT secrets must be ≥ 32 bytes.** The hardened JWT middleware rejects short secrets
181+
at startup to prevent brute-force forgery.
182+
183+
10. **`listen(0)` picks a random free port.** `listen(port?)` falls back to `config.server.port`
184+
and then `3000`; pass `0` to let Bun assign an ephemeral port.
185+
163186
## CI/CD
164187

165188
- Pushes to `main` trigger GitHub Actions (see `.github/workflows/`)

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ For production deployments with HTTPS:
167167
import { BunGateway } from 'bungate'
168168

169169
const gateway = new BunGateway({
170-
server: { port: 443 },
170+
server: { port: 443, hostname: 'localhost' },
171171
security: {
172172
tls: {
173173
enabled: true,
@@ -176,6 +176,8 @@ const gateway = new BunGateway({
176176
minVersion: 'TLSv1.3',
177177
redirectHTTP: true,
178178
redirectPort: 80,
179+
// Prevent open redirects via Host header
180+
redirectAllowedHosts: ['localhost'],
179181
},
180182
},
181183
})
@@ -188,6 +190,7 @@ gateway.addRoute({
188190
jwtOptions: {
189191
algorithms: ['HS256'],
190192
issuer: 'https://auth.example.com',
193+
audience: 'https://api.example.com',
191194
},
192195
},
193196
})
@@ -305,6 +308,11 @@ const gateway = new BunGateway({
305308
cluster: { enabled: true, workers: 4 },
306309
auth: {
307310
secret: process.env.JWT_SECRET,
311+
jwtOptions: {
312+
algorithms: ['HS256'],
313+
issuer: 'https://auth.myapp.com',
314+
audience: 'https://api.myapp.com',
315+
},
308316
excludePaths: ['/health', '/auth/*'],
309317
},
310318
cors: {

docs/API_REFERENCE.md

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ interface TLSConfig {
159159
cipherSuites?: string[]
160160
redirectHTTP?: boolean
161161
redirectPort?: number
162+
/** Allowed Host header values for the HTTP->HTTPS redirect server. Supports leading wildcards. */
163+
redirectAllowedHosts?: string[]
162164
}
163165
```
164166

@@ -174,11 +176,28 @@ const gateway = new BunGateway({
174176
minVersion: 'TLSv1.3',
175177
redirectHTTP: true,
176178
redirectPort: 80,
179+
redirectAllowedHosts: ['example.com', '*.example.com'],
177180
},
178181
},
179182
})
180183
```
181184

185+
#### TrustedProxiesConfig
186+
187+
```typescript
188+
interface TrustedProxiesConfig {
189+
enabled: boolean
190+
trustedIPs?: string[]
191+
trustedNetworks?: string[]
192+
maxForwardedDepth?: number
193+
trustAll?: boolean
194+
/** Honor CF-Connecting-IP only when the immediate proxy is Cloudflare. Default: false */
195+
trustCloudflare?: boolean
196+
/** Honor X-Real-IP / X-Client-IP from the validated immediate proxy. Default: false */
197+
trustXRealIP?: boolean
198+
}
199+
```
200+
182201
#### SecurityHeadersConfig
183202

184203
```typescript
@@ -246,21 +265,45 @@ security: {
246265

247266
```typescript
248267
interface AuthConfig {
249-
secret?: string
268+
/** Symmetric secret, PEM string, imported key, or a resolver function */
269+
secret?:
270+
| string
271+
| Uint8Array
272+
| JWTKeyLike
273+
| ((req: Request) => JWTKeyLike | Promise<JWTKeyLike>)
250274
jwksUri?: string
251-
jwtOptions?: {
252-
algorithms: string[]
253-
issuer?: string
254-
audience?: string
255-
maxAge?: string | number
256-
}
257-
apiKeys?: string[]
275+
jwks?: any
276+
jwtOptions?: Record<string, any>
277+
/** Required issuer claim (recommended) */
278+
issuer?: string | string[]
279+
/** Required audience claim (recommended) */
280+
audience?: string | string[]
281+
/** Explicitly allowed algorithms. Derived from the key type when omitted. */
282+
algorithms?: string[]
283+
apiKeys?:
284+
| string[]
285+
| ((
286+
apiKey: string,
287+
req: Request,
288+
) => boolean | object | Promise<boolean | object>)
258289
apiKeyHeader?: string
259-
apiKeyValidator?: (key: string, req: Request) => Promise<boolean> | boolean
290+
apiKeyValidator?: (
291+
key: string,
292+
req: Request,
293+
) => boolean | object | Promise<boolean | object>
294+
/** Boundary-aware path exclusions. `/public/*` excludes `/public/foo` but not `/publicity/foo */
260295
excludePaths?: string[]
261296
optional?: boolean
262-
getToken?: (req: Request) => string | null
263-
onError?: (error: Error, req: Request) => Response
297+
getToken?: (req: Request) => string | undefined | Promise<string | undefined>
298+
tokenHeader?: string
299+
tokenQuery?: string
300+
unauthorizedResponse?:
301+
| Response
302+
| ((error: Error, req: Request) => Response | object)
303+
onError?: (
304+
error: Error,
305+
req: Request,
306+
) => Response | void | Promise<Response | void>
264307
}
265308
```
266309

@@ -269,12 +312,10 @@ interface AuthConfig {
269312
```typescript
270313
const gateway = new BunGateway({
271314
auth: {
272-
secret: process.env.JWT_SECRET,
273-
jwtOptions: {
274-
algorithms: ['HS256'],
275-
issuer: 'https://auth.example.com',
276-
audience: 'https://api.example.com',
277-
},
315+
secret: process.env.JWT_SECRET, // HS256 secrets should be >= 32 bytes
316+
issuer: 'https://auth.example.com',
317+
audience: 'https://api.example.com',
318+
// algorithms is optional; Bungate derives it from the key type.
278319
excludePaths: ['/health', '/public/*'],
279320
},
280321
})
@@ -341,7 +382,7 @@ interface RouteConfig {
341382
circuitBreaker?: CircuitBreakerConfig
342383
timeout?: number
343384
middlewares?: Middleware[]
344-
proxy?: ProxyConfig
385+
proxy?: GatewayProxyOptions
345386
hooks?: RouteHooks
346387
}
347388
```
@@ -440,6 +481,14 @@ interface StickySessionConfig {
440481
secure?: boolean // Default: false
441482
httpOnly?: boolean // Default: true
442483
sameSite?: 'strict' | 'lax' | 'none'
484+
/** Maximum in-memory sessions. Oldest entries are evicted when the cap is hit. */
485+
maxSessions?: number // Default: 10000
486+
/**
487+
* How to handle unknown session cookies.
488+
* 'ignore' treats them as cookie-less (prevents memory DoS).
489+
* 'create' mints a new session for the unknown value.
490+
*/
491+
unknownCookiePolicy?: 'ignore' | 'create' // Default: 'ignore'
443492
}
444493
```
445494

@@ -469,6 +518,8 @@ interface RateLimitConfig {
469518
keyGenerator?: (req: Request) => string
470519
message?: string
471520
statusCode?: number
521+
/** Boundary-aware path exclusions for rate limiting */
522+
excludePaths?: string[]
472523
}
473524
```
474525

@@ -528,14 +579,22 @@ gateway.addRoute({
528579
})
529580
```
530581

531-
### ProxyConfig
582+
### GatewayProxyOptions
532583

533584
```typescript
534-
interface ProxyConfig {
585+
interface GatewayProxyOptions {
535586
timeout?: number
536587
headers?: Record<string, string | (() => string)>
537588
stripPath?: boolean
538589
preserveHostHeader?: boolean
590+
/** Rewrite incoming paths before forwarding (regex -> replacement, or function) */
591+
pathRewrite?: Record<string, string> | ((path: string) => string)
592+
/** Allowed redirect target hostnames when followRedirects is enabled. Supports leading wildcards. */
593+
redirectAllowlist?: string[]
594+
/** Allow redirects only to the same origin as the original upstream target. Default: true */
595+
redirectSameOrigin?: boolean
596+
/** Per-request fetch RequestInit options (used internally for SSRF protection) */
597+
request?: RequestInit
539598
}
540599
```
541600

@@ -553,6 +612,8 @@ gateway.addRoute({
553612
},
554613
stripPath: false,
555614
preserveHostHeader: true,
615+
// Redirects are followed manually only for same-origin or allow-listed hosts.
616+
redirectSameOrigin: true,
556617
},
557618
})
558619
```

docs/AUTHENTICATION.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,12 @@ import { BunGateway } from 'bungate'
4444
const gateway = new BunGateway({
4545
server: { port: 3000 },
4646
auth: {
47+
// HS256 secrets must be at least 32 bytes. Bungate derives the allowed
48+
// algorithm from the key type when algorithms is omitted.
4749
secret: process.env.JWT_SECRET,
48-
jwtOptions: {
49-
algorithms: ['HS256', 'RS256'],
50-
issuer: 'https://auth.myapp.com',
51-
audience: 'https://api.myapp.com',
52-
},
53-
// Paths that don't require authentication
50+
issuer: 'https://auth.myapp.com',
51+
audience: 'https://api.myapp.com',
52+
// Paths that don't require authentication (boundary-aware matching)
5453
excludePaths: [
5554
'/health',
5655
'/metrics',
@@ -86,12 +85,13 @@ gateway.addRoute({
8685
pattern: '/admin/*',
8786
target: 'http://admin-service:3000',
8887
auth: {
89-
secret: process.env.ADMIN_JWT_SECRET,
88+
// For RS256/ES256, `secret` may be a PEM public key or a CryptoKey.
89+
secret: process.env.ADMIN_JWT_PUBLIC_KEY!,
9090
jwtOptions: {
9191
algorithms: ['RS256'],
92-
issuer: 'https://auth.myapp.com',
93-
audience: 'https://admin.myapp.com',
9492
},
93+
issuer: 'https://auth.myapp.com',
94+
audience: 'https://admin.myapp.com',
9595
optional: false, // Authentication is required
9696
},
9797
})
@@ -102,10 +102,8 @@ gateway.addRoute({
102102
target: 'http://user-service:3000',
103103
auth: {
104104
secret: process.env.JWT_SECRET,
105-
jwtOptions: {
106-
algorithms: ['HS256'],
107-
issuer: 'https://auth.myapp.com',
108-
},
105+
issuer: 'https://auth.myapp.com',
106+
audience: 'https://api.myapp.com',
109107
},
110108
})
111109

@@ -117,6 +115,14 @@ gateway.addRoute({
117115
})
118116
```
119117

118+
### JWT Security Notes
119+
120+
- **Tokens must include an `exp` claim.** Bungate rejects tokens without an expiration time.
121+
- **Pin `issuer` and `audience`** to prevent cross-service token replay, especially when using JWKS.
122+
- **HS256 secrets must be at least 32 bytes.** Short secrets are rejected at startup.
123+
- **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.
124+
- **`excludePaths` uses boundary-aware matching.** `/public/*` excludes `/public/foo` but not `/publicity/foo`.
125+
120126
### Custom Token Extraction
121127

122128
By default, JWT tokens are extracted from the `Authorization` header. You can customize this:

docs/DOCUMENTATION.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ Enterprise-grade security features and best practices for production deployments
4646
**Contents:**
4747

4848
- Threat model
49-
- TLS/HTTPS configuration
50-
- Input validation & sanitization
49+
- TLS/HTTPS configuration (redirect host allowlisting, cipher/version mapping)
50+
- Input validation & sanitization (boundary-aware exclusions, encoding validation)
5151
- Security headers
52-
- Session management
53-
- Trusted proxy configuration
52+
- Session management (bounded sticky sessions)
53+
- Trusted proxy configuration (Cloudflare/X-Real-IP gating)
5454
- Request size limits
55+
- JWT authentication hardening (`exp` required, algorithm derivation, aud/iss support)
5556
- JWT key rotation
5657
- Security checklist
5758

docs/EXAMPLES.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,16 @@ const gateway = new BunGateway({
3838
key: './certs/key.pem',
3939
redirectHTTP: true,
4040
redirectPort: 80,
41+
redirectAllowedHosts: ['api.myapp.com', '*.myapp.com'],
4142
},
4243
},
4344
auth: {
4445
secret: process.env.JWT_SECRET,
4546
jwtOptions: {
4647
algorithms: ['HS256'],
47-
issuer: 'https://auth.myapp.com',
48-
audience: 'https://api.myapp.com',
4948
},
49+
issuer: 'https://auth.myapp.com',
50+
audience: 'https://api.myapp.com',
5051
excludePaths: ['/health', '/metrics', '/auth/*', '/public/*'],
5152
},
5253
cors: {
@@ -139,7 +140,9 @@ gateway.addRoute({
139140
rateLimit: {
140141
max: 1000,
141142
windowMs: 60000,
142-
keyGenerator: (req) => req.headers.get('x-forwarded-for') || 'unknown',
143+
// Prefer a trusted client identifier. Avoid raw X-Forwarded-For unless
144+
// trusted proxies are explicitly configured.
145+
keyGenerator: (req) => req.headers.get('cf-connecting-ip') || 'unknown',
143146
},
144147
})
145148

@@ -234,6 +237,8 @@ gateway.addRoute({
234237
ttl: 3600000,
235238
secure: true,
236239
httpOnly: true,
240+
maxSessions: 10000,
241+
unknownCookiePolicy: 'ignore',
237242
},
238243
},
239244
})

0 commit comments

Comments
 (0)