Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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/`)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -176,6 +176,8 @@ const gateway = new BunGateway({
minVersion: 'TLSv1.3',
redirectHTTP: true,
redirectPort: 80,
// Prevent open redirects via Host header
redirectAllowedHosts: ['localhost'],
},
},
})
Expand All @@ -188,6 +190,7 @@ gateway.addRoute({
jwtOptions: {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
},
},
})
Expand Down Expand Up @@ -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: {
Expand Down
101 changes: 81 additions & 20 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
```

Expand All @@ -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
Expand Down Expand Up @@ -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<JWTKeyLike>)
jwksUri?: string
jwtOptions?: {
algorithms: string[]
issuer?: string
audience?: string
maxAge?: string | number
}
apiKeys?: string[]
jwks?: any
jwtOptions?: Record<string, any>
/** 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<boolean | object>)
apiKeyHeader?: string
apiKeyValidator?: (key: string, req: Request) => Promise<boolean> | boolean
apiKeyValidator?: (
key: string,
req: Request,
) => boolean | object | Promise<boolean | object>
/** 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<string | undefined>
tokenHeader?: string
tokenQuery?: string
unauthorizedResponse?:
| Response
| ((error: Error, req: Request) => Response | object)
onError?: (
error: Error,
req: Request,
) => Response | void | Promise<Response | void>
}
```

Expand All @@ -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/*'],
},
})
Expand Down Expand Up @@ -341,7 +382,7 @@ interface RouteConfig {
circuitBreaker?: CircuitBreakerConfig
timeout?: number
middlewares?: Middleware[]
proxy?: ProxyConfig
proxy?: GatewayProxyOptions
hooks?: RouteHooks
}
```
Expand Down Expand Up @@ -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'
}
```

Expand Down Expand Up @@ -469,6 +518,8 @@ interface RateLimitConfig {
keyGenerator?: (req: Request) => string
message?: string
statusCode?: number
/** Boundary-aware path exclusions for rate limiting */
excludePaths?: string[]
}
```

Expand Down Expand Up @@ -528,14 +579,22 @@ gateway.addRoute({
})
```

### ProxyConfig
### GatewayProxyOptions

```typescript
interface ProxyConfig {
interface GatewayProxyOptions {
timeout?: number
headers?: Record<string, string | (() => string)>
stripPath?: boolean
preserveHostHeader?: boolean
/** Rewrite incoming paths before forwarding (regex -> replacement, or function) */
pathRewrite?: Record<string, string> | ((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
}
```

Expand All @@ -553,6 +612,8 @@ gateway.addRoute({
},
stripPath: false,
preserveHostHeader: true,
// Redirects are followed manually only for same-origin or allow-listed hosts.
redirectSameOrigin: true,
},
})
```
Expand Down
32 changes: 19 additions & 13 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
},
})
Expand All @@ -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',
},
})

Expand All @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions docs/DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
},
})

Expand Down Expand Up @@ -234,6 +237,8 @@ gateway.addRoute({
ttl: 3600000,
secure: true,
httpOnly: true,
maxSessions: 10000,
unknownCookiePolicy: 'ignore',
},
},
})
Expand Down
Loading
Loading