ingenium 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import type { KeyObject } from 'node:crypto'
3
- import { IngeniumUnauthorizedError } from '../errors.ts'
3
+ import { IngeniumError, IngeniumUnauthorizedError } from '../errors.ts'
4
4
  import type { IngeniumMiddleware } from '../middleware/types.ts'
5
5
  import type { IngeniumContext } from '../context/context.ts'
6
6
  import type {
@@ -17,6 +17,10 @@ import { verifyJwt, type KidTaggedKey, type VerifyKeyMaterial } from './verify.t
17
17
  import { fetchJwks } from './jwks.ts'
18
18
 
19
19
  const DEFAULT_ALGORITHMS: readonly JwtAlgorithm[] = ['HS256']
20
+ // When the caller wires up JWKS (always asymmetric) but doesn't pin an
21
+ // algorithm, defaulting to the HMAC `HS256` would be a footgun — fall back to
22
+ // the most common asymmetric default instead.
23
+ const DEFAULT_ASYMMETRIC_ALGORITHMS: readonly JwtAlgorithm[] = ['RS256']
20
24
  const DEFAULT_JWKS_TTL_MS = 10 * 60 * 1000
21
25
  const SUPPORTED: ReadonlySet<JwtAlgorithm> = new Set([
22
26
  'HS256', 'HS384', 'HS512',
@@ -25,6 +29,25 @@ const SUPPORTED: ReadonlySet<JwtAlgorithm> = new Set([
25
29
  'ES256', 'ES384', 'ES512',
26
30
  ])
27
31
 
32
+ /**
33
+ * 500 — the configured algorithm allowlist and the supplied key material are
34
+ * from different families (an HMAC alg paired with an asymmetric PEM / public
35
+ * key, or vice versa). Allowing this is the canonical algorithm-confusion
36
+ * footgun: an attacker forges `HS256` tokens using the server's *public* key
37
+ * as the HMAC secret. We refuse the configuration at construction time so the
38
+ * mistake surfaces at boot, not as a silent auth bypass.
39
+ */
40
+ export class IngeniumJwtKeyAlgMismatchError extends IngeniumError {
41
+ constructor(message: string) {
42
+ super(500, 'JWT_KEY_ALG_MISMATCH', message)
43
+ }
44
+ }
45
+
46
+ /** True for the HMAC (symmetric) algorithm family. */
47
+ function isHmacAlg(alg: JwtAlgorithm): boolean {
48
+ return alg === 'HS256' || alg === 'HS384' || alg === 'HS512'
49
+ }
50
+
28
51
  /** Default token reader — `Authorization: Bearer <token>`. */
29
52
  const defaultGetToken: JwtTokenReader = (ctx) => {
30
53
  const raw = ctx.headers['authorization']
@@ -102,7 +125,13 @@ export function jwtMiddleware<T = Record<string, unknown>>(
102
125
  }
103
126
  }
104
127
 
105
- const algorithms = (opts.algorithms ?? DEFAULT_ALGORITHMS).slice() as JwtAlgorithm[]
128
+ // Algorithm defaulting is family-aware. The historical `HS256` default is
129
+ // only safe for symmetric (string-secret) setups; when the caller leans on
130
+ // JWKS (always asymmetric) without pinning an algorithm, defaulting to an
131
+ // HMAC alg would silently invite the key-confusion bypass. In that case we
132
+ // default to `RS256` instead so the families can never disagree by accident.
133
+ const defaultAlgorithms = hasJwks ? DEFAULT_ASYMMETRIC_ALGORITHMS : DEFAULT_ALGORITHMS
134
+ const algorithms = (opts.algorithms ?? defaultAlgorithms).slice() as JwtAlgorithm[]
106
135
  if (algorithms.length === 0) {
107
136
  throw new Error('jwtMiddleware: `algorithms` must contain at least one algorithm')
108
137
  }
@@ -118,6 +147,7 @@ export function jwtMiddleware<T = Record<string, unknown>>(
118
147
 
119
148
  const required = opts.required ?? true
120
149
  const clockSkewSeconds = opts.clockSkewSeconds ?? 5
150
+ const requireExp = opts.requireExp ?? true
121
151
  const jwksCacheMs = opts.jwksCacheMs ?? DEFAULT_JWKS_TTL_MS
122
152
  const jwksUrl = hasJwks ? opts.jwksUrl! : null
123
153
  const getToken = opts.getToken ?? defaultGetToken
@@ -133,6 +163,26 @@ export function jwtMiddleware<T = Record<string, unknown>>(
133
163
  ? (opts.secret as JwtSecretResolver<T>)
134
164
  : null
135
165
 
166
+ // ── Key/algorithm family confinement ─────────────────────────────────────
167
+ // If the allowlist contains an HMAC alg, none of the STATIC key material may
168
+ // be asymmetric (a PEM string/Buffer or a public/private KeyObject). Pairing
169
+ // those is the algorithm-confusion bypass: an attacker forges `HS256` tokens
170
+ // using the asymmetric *public* key as the HMAC secret. Refuse at boot.
171
+ // (A function resolver / JWKS is checked per-request by the verifier, which
172
+ // never runs HMAC against an asymmetric key.)
173
+ if (algorithms.some(isHmacAlg)) {
174
+ for (const k of staticKeys) {
175
+ if (staticKeyIsAsymmetric(k)) {
176
+ throw new IngeniumJwtKeyAlgMismatchError(
177
+ 'jwtMiddleware: an HMAC algorithm (HS256/384/512) was paired with an ' +
178
+ 'asymmetric key (PEM or public/private KeyObject). This enables an ' +
179
+ 'algorithm-confusion forgery — use an asymmetric algorithm (RS*/PS*/ES*) ' +
180
+ 'for asymmetric keys, or a raw shared secret for HMAC.',
181
+ )
182
+ }
183
+ }
184
+ }
185
+
136
186
  return async (ctx, next) => {
137
187
  const token = await getToken(ctx)
138
188
  if (!token) {
@@ -171,7 +221,7 @@ export function jwtMiddleware<T = Record<string, unknown>>(
171
221
  }
172
222
 
173
223
  // Build VerifyOptions without spreading undefined keys (exactOptionalPropertyTypes).
174
- const verifyOpts: Parameters<typeof verifyJwt>[2] = { algorithms, clockSkewSeconds }
224
+ const verifyOpts: Parameters<typeof verifyJwt>[2] = { algorithms, clockSkewSeconds, requireExp }
175
225
  if (opts.audience !== undefined) verifyOpts.audience = opts.audience
176
226
  if (opts.issuer !== undefined) verifyOpts.issuer = opts.issuer
177
227
  if (opts.maxAgeSeconds !== undefined) verifyOpts.maxAgeSeconds = opts.maxAgeSeconds
@@ -230,6 +280,27 @@ function coerceJwtKey(k: JwtKey): VerifyKeyMaterial | KidTaggedKey {
230
280
  throw new Error('jwtMiddleware: invalid `secret` entry — expected string, Buffer, KeyObject, or { kid, key }')
231
281
  }
232
282
 
283
+ /**
284
+ * Classify a normalised static key as asymmetric (PEM blob or a public/private
285
+ * KeyObject). Used by the construction-time family guard — a `kid`-tagged entry
286
+ * is inspected by its inner `key`.
287
+ */
288
+ function staticKeyIsAsymmetric(entry: VerifyKeyMaterial | KidTaggedKey): boolean {
289
+ const key: VerifyKeyMaterial =
290
+ typeof entry === 'object' && entry !== null && !Buffer.isBuffer(entry) && 'key' in entry
291
+ ? (entry as KidTaggedKey).key
292
+ : (entry as VerifyKeyMaterial)
293
+ if (typeof key === 'string') return looksLikePem(key)
294
+ if (Buffer.isBuffer(key)) return looksLikePem(key.toString('latin1'))
295
+ // KeyObject: 'public' / 'private' are asymmetric; 'secret' is HMAC-eligible.
296
+ return (key as KeyObject).type === 'public' || (key as KeyObject).type === 'private'
297
+ }
298
+
299
+ /** A PEM-armored blob is asymmetric key material, never an HMAC secret. */
300
+ function looksLikePem(s: string): boolean {
301
+ return s.trimStart().startsWith('-----BEGIN')
302
+ }
303
+
233
304
  function isKeyObject(v: unknown): v is KeyObject {
234
305
  // Avoid a hard import-time check; KeyObjects from node:crypto carry a
235
306
  // distinctive `asymmetricKeyType` getter or `type` property of
package/src/jwt/types.ts CHANGED
@@ -44,6 +44,7 @@ export type JwtVerifyError =
44
44
  | { error: 'unsupported_alg' }
45
45
  | { error: 'bad_signature' }
46
46
  | { error: 'expired' }
47
+ | { error: 'missing_exp' }
47
48
  | { error: 'not_yet_valid' }
48
49
  | { error: 'too_old' }
49
50
  | { error: 'aud_mismatch' }
@@ -107,6 +108,17 @@ export interface JwtOptions<T = Record<string, unknown>> {
107
108
  maxAgeSeconds?: number
108
109
  /** Leeway for `nbf` / `exp` checks, in seconds. Default `5`. */
109
110
  clockSkewSeconds?: number
111
+ /**
112
+ * Require a numeric `exp` claim. Default `true`.
113
+ *
114
+ * Why default-on: a token that omits `exp` (or carries a non-numeric one)
115
+ * would otherwise verify forever — a stolen token never stops working. We
116
+ * refuse those by default and only relax it for callers who deliberately
117
+ * issue non-expiring tokens (set this to `false`). Either way a present
118
+ * `exp`/`nbf`/`iat` that is not a finite number is treated as malformed, not
119
+ * silently skipped.
120
+ */
121
+ requireExp?: boolean
110
122
  /**
111
123
  * If `true` (default), missing tokens raise `IngeniumUnauthorizedError`.
112
124
  * If `false`, missing tokens just call `next()` with no `ctx.jwt`.
package/src/jwt/verify.ts CHANGED
@@ -65,6 +65,11 @@ export interface VerifyOptions {
65
65
  issuer?: string | readonly string[]
66
66
  maxAgeSeconds?: number
67
67
  clockSkewSeconds?: number
68
+ /**
69
+ * Require a finite numeric `exp`. Default `true` — a token without an
70
+ * expiry would otherwise verify forever (see middleware `requireExp`).
71
+ */
72
+ requireExp?: boolean
68
73
  /** Override "now" for deterministic tests. Returns seconds since epoch. */
69
74
  nowSeconds?: () => number
70
75
  }
@@ -86,6 +91,27 @@ function decodeJsonSegment<T = unknown>(segment: string): T | null {
86
91
  }
87
92
  }
88
93
 
94
+ /**
95
+ * Does this key material belong to the symmetric (HMAC) family?
96
+ *
97
+ * This is the load-bearing guard against the classic algorithm-confusion
98
+ * attack: a server configured for an asymmetric alg but tricked into running
99
+ * HMAC verifies the forgery with the PUBLIC key as the shared secret. A PEM
100
+ * (or any asymmetric `KeyObject`) is therefore NEVER eligible as an HMAC key.
101
+ * Only a raw string/Buffer secret, or a `secret`-type `KeyObject`, qualifies.
102
+ */
103
+ function isSymmetricKey(key: VerifyKeyMaterial): boolean {
104
+ if (typeof key === 'string') return !looksLikePem(key)
105
+ if (Buffer.isBuffer(key)) return !looksLikePem(key.toString('latin1'))
106
+ // KeyObject: only the 'secret' type is HMAC-eligible; 'public'/'private' are not.
107
+ return (key as KeyObject).type === 'secret'
108
+ }
109
+
110
+ /** A PEM-armored blob is asymmetric key material, never an HMAC secret. */
111
+ function looksLikePem(s: string): boolean {
112
+ return s.trimStart().startsWith('-----BEGIN')
113
+ }
114
+
89
115
  /**
90
116
  * Constant-time HMAC verification.
91
117
  *
@@ -94,6 +120,12 @@ function decodeJsonSegment<T = unknown>(segment: string): T | null {
94
120
  * computed signature against the supplied one only after the explicit length
95
121
  * check; both branches return `false` in O(constant) time relative to the
96
122
  * caller's view (the throw path never executes).
123
+ *
124
+ * The caller MUST have already established via {@link isSymmetricKey} that
125
+ * `secret` is HMAC-eligible — a public/private `KeyObject` would throw inside
126
+ * `export({format:'buffer'})`, and a PEM string would be silently (and
127
+ * dangerously) used as a shared secret. This function additionally try/catches
128
+ * so a mis-typed key degrades to `false` (bad_signature) rather than a 500.
97
129
  */
98
130
  function hmacVerifies(
99
131
  digest: string,
@@ -101,14 +133,18 @@ function hmacVerifies(
101
133
  signingInput: string,
102
134
  sig: Buffer,
103
135
  ): boolean {
104
- // HMAC accepts string or Buffer; KeyObject would be unusual but handle it.
105
- const secretInput: string | Buffer =
106
- typeof secret === 'string' || Buffer.isBuffer(secret)
107
- ? secret
108
- : secret.export({ format: 'buffer' } as never)
109
- const expected = createHmac(digest, secretInput).update(signingInput).digest()
110
- if (sig.length !== expected.length) return false
111
- return timingSafeEqual(sig, expected)
136
+ try {
137
+ // HMAC accepts string or Buffer; a 'secret' KeyObject exports to a buffer.
138
+ const secretInput: string | Buffer =
139
+ typeof secret === 'string' || Buffer.isBuffer(secret)
140
+ ? secret
141
+ : secret.export({ format: 'buffer' } as never)
142
+ const expected = createHmac(digest, secretInput).update(signingInput).digest()
143
+ if (sig.length !== expected.length) return false
144
+ return timingSafeEqual(sig, expected)
145
+ } catch {
146
+ return false
147
+ }
112
148
  }
113
149
 
114
150
  /**
@@ -173,6 +209,15 @@ function asymmetricVerifies(
173
209
  }
174
210
  }
175
211
 
212
+ /**
213
+ * A JWT temporal claim (`exp`/`nbf`/`iat`) is only meaningful as a finite
214
+ * number of seconds. `NaN`/`Infinity` would defeat the `<=` comparisons, so
215
+ * they're rejected the same as a missing claim.
216
+ */
217
+ function isFiniteNumber(v: unknown): v is number {
218
+ return typeof v === 'number' && Number.isFinite(v)
219
+ }
220
+
176
221
  /** Allowed claim resolution — both single value and array forms are common in spec. */
177
222
  function audienceMatches(claim: unknown, expected: string | readonly string[]): boolean {
178
223
  const wanted = typeof expected === 'string' ? [expected] : expected
@@ -268,12 +313,20 @@ export function verifyJwt<T = Record<string, unknown>>(
268
313
 
269
314
  let signatureOk = false
270
315
  for (const candidate of candidates) {
316
+ // Bind the algorithm family to the key family. An HMAC alg must never be
317
+ // attempted against an asymmetric key (the algorithm-confusion / key-as-
318
+ // secret forgery), and an asymmetric alg must never run against a raw
319
+ // symmetric secret. A mismatched candidate is simply skipped — if NONE of
320
+ // the supplied keys are family-compatible we fall through to bad_signature.
321
+ const candidateIsSymmetric = isSymmetricKey(candidate)
271
322
  if (spec.family === 'hmac') {
323
+ if (!candidateIsSymmetric) continue
272
324
  if (hmacVerifies(spec.digest, candidate, signingInput, sig)) {
273
325
  signatureOk = true
274
326
  break
275
327
  }
276
328
  } else {
329
+ if (candidateIsSymmetric) continue
277
330
  if (asymmetricVerifies(spec, candidate, signingInput, sig)) {
278
331
  signatureOk = true
279
332
  break
@@ -287,14 +340,25 @@ export function verifyJwt<T = Record<string, unknown>>(
287
340
  const skew = opts.clockSkewSeconds ?? 5
288
341
  const claims = payload as Record<string, unknown>
289
342
 
290
- if (typeof claims.exp === 'number') {
343
+ // A present-but-non-numeric temporal claim is an attempt to slip past the
344
+ // numeric checks below (e.g. `exp: "9999999999"` as a string). Treat any
345
+ // such claim as malformed rather than silently skipping the comparison.
346
+ if ('exp' in claims && !isFiniteNumber(claims.exp)) return { error: 'malformed' }
347
+ if ('nbf' in claims && !isFiniteNumber(claims.nbf)) return { error: 'malformed' }
348
+ if ('iat' in claims && !isFiniteNumber(claims.iat)) return { error: 'malformed' }
349
+
350
+ const requireExp = opts.requireExp ?? true
351
+ if (isFiniteNumber(claims.exp)) {
291
352
  if (claims.exp <= now - skew) return { error: 'expired' }
353
+ } else if (requireExp) {
354
+ // No usable expiry — refuse rather than accept a non-expiring token.
355
+ return { error: 'missing_exp' }
292
356
  }
293
- if (typeof claims.nbf === 'number') {
357
+ if (isFiniteNumber(claims.nbf)) {
294
358
  if (claims.nbf > now + skew) return { error: 'not_yet_valid' }
295
359
  }
296
360
  if (typeof opts.maxAgeSeconds === 'number') {
297
- if (typeof claims.iat !== 'number') return { error: 'too_old' }
361
+ if (!isFiniteNumber(claims.iat)) return { error: 'too_old' }
298
362
  if (claims.iat + opts.maxAgeSeconds <= now - skew) return { error: 'too_old' }
299
363
  }
300
364
  if (opts.audience !== undefined) {
@@ -6,6 +6,12 @@ import {
6
6
  } from '../errors.ts'
7
7
  import type { ProblemDetails, ResolvedProblemDetailsOptions } from './types.ts'
8
8
 
9
+ /**
10
+ * Read once at module load so V8 dead-code-eliminates the dev-only branch in
11
+ * production builds. See CLAUDE.md "Dev-mode diagnostics" rule.
12
+ */
13
+ const IS_DEV = process.env.NODE_ENV !== 'production'
14
+
9
15
  /**
10
16
  * Maps known framework error codes to short, human-readable titles. Falls
11
17
  * back to the standard HTTP reason phrase, then to the error's own message.
@@ -101,13 +107,23 @@ export function toProblemDetails(
101
107
  return problem
102
108
  }
103
109
 
104
- // Unknown error — generic 500.
110
+ // Unknown error — generic 500. Unlike IngeniumError (whose `message` is
111
+ // developer-authored and safe to surface), an arbitrary thrown value's
112
+ // message can leak internal infrastructure detail (DB DSNs with hostnames /
113
+ // credentials, file paths, driver internals). In production we therefore
114
+ // replace it with a generic phrase and only expose the raw message in dev or
115
+ // when the explicit `includeStack` debug opt-in is enabled.
116
+ const exposeMessage = IS_DEV || opts.includeStack
105
117
  const message = (err as Error)?.message
118
+ const detail =
119
+ exposeMessage && typeof message === 'string' && message.length > 0
120
+ ? message
121
+ : 'Internal Server Error'
106
122
  const problem: ProblemDetails = {
107
123
  type: 'about:blank',
108
124
  title: STATUS_REASON[500]!,
109
125
  status: 500,
110
- detail: typeof message === 'string' && message.length > 0 ? message : 'Internal Server Error',
126
+ detail,
111
127
  }
112
128
 
113
129
  const instance = opts.instance(ctx)
@@ -16,6 +16,17 @@
16
16
  * (`fc00::/7`) form. Single addresses without `/` match exactly.
17
17
  */
18
18
 
19
+ const IS_DEV = process.env.NODE_ENV !== 'production'
20
+
21
+ /**
22
+ * `trust: true` fully trusts a client-supplied `X-Forwarded-For` header, so any
23
+ * caller can spoof `ctx.ip`. We warn exactly once (dev only) rather than change
24
+ * the default, because the boolean form is documented Express-compatible behavior
25
+ * and silently altering it would break apps that legitimately sit behind a single
26
+ * trusted reverse proxy.
27
+ */
28
+ let warnedTrustTrue = false
29
+
19
30
  export type TrustProxy =
20
31
  | boolean
21
32
  | number
@@ -65,6 +76,17 @@ export function resolveForwarded(
65
76
 
66
77
  let trustedIp = remoteAddress
67
78
  if (typeof trust === 'boolean' && trust === true) {
79
+ if (IS_DEV && !warnedTrustTrue) {
80
+ warnedTrustTrue = true
81
+ try {
82
+ process.emitWarning(
83
+ "trustProxy: true fully trusts the client-supplied X-Forwarded-For header, so ctx.ip can be spoofed by any caller. This bypasses IP rate-limits/allowlists and poisons audit logs. For production, set trustProxy to a hop count (e.g. 1) or a CIDR/subnet trust list (e.g. ['loopback', '10.0.0.0/8']) so only your reverse proxy is trusted.",
84
+ { code: 'INGENIUM_TRUST_PROXY_TRUE' },
85
+ )
86
+ } catch {
87
+ // Worker runtimes can throw on emitWarning; the warning is best-effort.
88
+ }
89
+ }
68
90
  trustedIp = fullChain[0] ?? remoteAddress
69
91
  } else if (typeof trust === 'number') {
70
92
  // Skip `trust` hops from the right (the rightmost is the immediate peer).
@@ -7,6 +7,12 @@ import type { Session, SessionCookieOptions, SessionOptions, SessionStore } from
7
7
 
8
8
  // ───── Constants ────────────────────────────────────────────────────────────
9
9
 
10
+ /**
11
+ * Read once at module load so the dev-only warning branch is dead-code
12
+ * eliminated in production builds. See `context/context.ts` for the pattern.
13
+ */
14
+ const IS_DEV = process.env.NODE_ENV !== 'production'
15
+
10
16
  const DEFAULT_COOKIE_NAME = 'ingenium.sid'
11
17
  const DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 * 7 // 7 days
12
18
  /** ID byte length — 18 bytes → 24 base64url chars, ~144 bits of entropy. */
@@ -248,8 +254,10 @@ class SessionImpl implements Session {
248
254
  * - HMAC-SHA-256 over the session id, base64url-encoded; verified with
249
255
  * `timingSafeEqual`.
250
256
  * - 144-bit (18-byte) random ids.
251
- * - Defaults: `HttpOnly`, `SameSite=Lax`, `Path=/`. Set `secure: true`
252
- * behind TLS to enable `Secure`.
257
+ * - Defaults: `HttpOnly`, `SameSite=Lax`, `Path=/`. `Secure` defaults ON in
258
+ * production (`NODE_ENV==='production'`) and OFF in dev so http://localhost
259
+ * keeps working; set `cookie.secure: false` to force it off in production
260
+ * (a dev warning fires), or `true` to force it on everywhere.
253
261
  * - Tampered or unknown cookies silently issue a fresh session — never an
254
262
  * error response, since this is an attacker-influenced surface.
255
263
  */
@@ -265,9 +273,34 @@ export function sessionMiddleware(opts: SessionOptions): IngeniumMiddleware {
265
273
  const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME
266
274
  const maxAgeSeconds = opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS
267
275
  const rolling = opts.rolling ?? false
268
- const cookieOpts: SessionCookieOptions = opts.cookie ?? {}
276
+ const baseCookieOpts: SessionCookieOptions = opts.cookie ?? {}
269
277
  const store: SessionStore = opts.store ?? new MemoryStore()
270
278
 
279
+ // Safe-by-default `Secure`: when the caller didn't say either way, enable it
280
+ // outside dev so a session cookie can't ride a plaintext-HTTP request and be
281
+ // sniffed. An explicit `secure: false` still wins — that's the escape hatch
282
+ // for serving over http in local/dev environments. We resolve this once and
283
+ // bake it into the cookie options so every Set-Cookie below is consistent.
284
+ const resolvedSecure = baseCookieOpts.secure ?? !IS_DEV
285
+ const cookieOpts: SessionCookieOptions = { ...baseCookieOpts, secure: resolvedSecure }
286
+
287
+ // In production, a session cookie without `Secure` is a real exposure; surface
288
+ // it loudly but exactly once. The IS_DEV gate is inverted here on purpose —
289
+ // we only care about the missing flag when NOT in dev.
290
+ if (!IS_DEV && resolvedSecure === false) {
291
+ try {
292
+ process.emitWarning(
293
+ 'ingenium: sessionMiddleware is issuing a session cookie WITHOUT the Secure ' +
294
+ 'attribute in production (cookie.secure === false). The cookie can be sent ' +
295
+ 'over plaintext HTTP and intercepted. Remove `cookie.secure: false` to use ' +
296
+ 'the safe production default, or terminate TLS in front of the app.',
297
+ { type: 'IngeniumSessionInsecureCookieWarning' },
298
+ )
299
+ } catch {
300
+ /* worker contexts may throw on emitWarning */
301
+ }
302
+ }
303
+
271
304
  return async (ctx, next) => {
272
305
  const cookies = parseCookieHeader(ctx.headers.cookie as string | undefined)
273
306
  const raw = cookies[cookieName]
@@ -15,7 +15,20 @@ export interface SessionCookieOptions {
15
15
  httpOnly?: boolean
16
16
  /** Cookie `SameSite` attribute. @default 'lax' */
17
17
  sameSite?: 'lax' | 'strict' | 'none'
18
- /** Cookie `Secure` attribute. @default false */
18
+ /**
19
+ * Cookie `Secure` attribute.
20
+ *
21
+ * When left undefined, {@link sessionMiddleware} resolves it safely: ON in
22
+ * production (`NODE_ENV==='production'`) so the cookie cannot ride plaintext
23
+ * HTTP, OFF in dev so `http://localhost` keeps working. Set `false`
24
+ * explicitly to opt out in production (emits a dev warning), or `true` to
25
+ * force it on in every environment.
26
+ *
27
+ * Note: the raw {@link serializeCookie} helper still treats `undefined` as
28
+ * off — only the middleware applies the environment-aware default.
29
+ *
30
+ * @default undefined (production → Secure, dev → not Secure)
31
+ */
19
32
  secure?: boolean
20
33
  }
21
34
 
@@ -34,6 +34,28 @@ function mimeFor(file: string): string {
34
34
  return MIME_TYPES[ext] ?? 'application/octet-stream'
35
35
  }
36
36
 
37
+ /**
38
+ * Root confinement: `target` must be `absRoot` itself or a descendant of it.
39
+ * Factored out because it must run on the FINAL resolved target — after the
40
+ * `extensions` and `index` retry loops mutate `target` — not just the decoded
41
+ * request path. A user-configurable `index`/`extensions` value containing `..`
42
+ * (or any join that escapes) would otherwise slip past the up-front check.
43
+ */
44
+ function isUnderRoot(absRoot: string, target: string): boolean {
45
+ return target === absRoot || target.startsWith(absRoot + path.sep)
46
+ }
47
+
48
+ /**
49
+ * True if any path segment of `target` below `absRoot` begins with a dot.
50
+ * Re-checked on the final target so an `index`/`extensions` value that resolves
51
+ * to a dotfile (e.g. `index: '.env'`) is still subject to the dotfile policy.
52
+ */
53
+ function hasDotfileSegment(absRoot: string, target: string): boolean {
54
+ const rel = path.relative(absRoot, target)
55
+ if (rel.length === 0) return false
56
+ return rel.split(/[/\\]/).some((s) => s.length > 0 && s.startsWith('.'))
57
+ }
58
+
37
59
  function makeEtag(stats: Stats): string {
38
60
  // Express-style weak etag: W/"<size>-<mtimeMs-as-hex>"
39
61
  return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`
@@ -112,19 +134,13 @@ export function staticMiddleware(root: string, opts: StaticOptions = {}): Ingeni
112
134
  // Path-traversal protection: resolve, then ensure it stays under root.
113
135
  const joined = path.join(absRoot, urlPath)
114
136
  const resolved = path.resolve(joined)
115
- const isUnderRoot =
116
- resolved === absRoot ||
117
- resolved.startsWith(absRoot + path.sep)
118
- if (!isUnderRoot) {
137
+ if (!isUnderRoot(absRoot, resolved)) {
119
138
  ctx.status(403).text('Forbidden')
120
139
  return
121
140
  }
122
141
 
123
142
  // Dotfile policy: check every segment of the path BELOW root.
124
- const rel = path.relative(absRoot, resolved)
125
- const segments = rel.length === 0 ? [] : rel.split(/[/\\]/)
126
- const hasDot = segments.some((s) => s.length > 0 && s.startsWith('.'))
127
- if (hasDot) {
143
+ if (hasDotfileSegment(absRoot, resolved)) {
128
144
  if (dotfiles === 'deny') {
129
145
  ctx.status(403).text('Forbidden')
130
146
  return
@@ -182,6 +198,25 @@ export function staticMiddleware(root: string, opts: StaticOptions = {}): Ingeni
182
198
  return next()
183
199
  }
184
200
 
201
+ // Re-run confinement + dotfile policy on the FINAL target. The extensions
202
+ // and index retry loops above mutate `target` (appending `.ext` or joining
203
+ // a user-configurable index name), so the up-front checks on `resolved` are
204
+ // not authoritative for what we are about to stream.
205
+ if (!isUnderRoot(absRoot, target)) {
206
+ ctx.status(403).text('Forbidden')
207
+ return
208
+ }
209
+ if (hasDotfileSegment(absRoot, target)) {
210
+ if (dotfiles === 'deny') {
211
+ ctx.status(403).text('Forbidden')
212
+ return
213
+ }
214
+ if (dotfiles === 'ignore') {
215
+ return next()
216
+ }
217
+ // 'allow' falls through.
218
+ }
219
+
185
220
  // ───── Cacheable response headers ─────
186
221
  const etag = makeEtag(stats)
187
222
  const lastModified = new Date(stats.mtimeMs).toUTCString()
package/src/ws/index.ts CHANGED
@@ -41,6 +41,8 @@ import type {
41
41
  export type {
42
42
  WebSocketHandler,
43
43
  WebSocketHandlerOptions,
44
+ WebSocketOriginOption,
45
+ WebSocketOriginVerifier,
44
46
  WsIntegrator,
45
47
  WsRegistrar,
46
48
  WebSocket,
@@ -14,10 +14,17 @@ import type { HttpMethod } from '../router/types.ts'
14
14
  import type {
15
15
  WebSocketHandler,
16
16
  WebSocketHandlerOptions,
17
+ WebSocketOriginOption,
17
18
  WsRegistrar,
18
19
  WsRoute,
19
20
  } from './types.ts'
20
21
 
22
+ /**
23
+ * Read once at module load so V8 dead-code-eliminates the dev warning in
24
+ * production builds (`if (false) { ... }`). See CLAUDE.md.
25
+ */
26
+ const IS_DEV = process.env.NODE_ENV !== 'production'
27
+
21
28
  /**
22
29
  * Attempt to detect whether `ws` is installed. Used by the test suite to
23
30
  * `describe.skipIf` the WS suite when the optional peer dep is missing.
@@ -53,6 +60,21 @@ export function createWebSocketRegistrar(): WsRegistrar {
53
60
  if (routes.has(path)) {
54
61
  throw new Error(`ingenium.ws: path "${path}" already has a WebSocket handler`)
55
62
  }
63
+ // WS handlers run OUTSIDE the middleware pipeline, so a route with no
64
+ // `origin` policy is open to Cross-Site WebSocket Hijacking — a browser
65
+ // attaches the victim's cookies to a cross-origin upgrade. Nudge the
66
+ // developer to opt into an Origin check (or authenticate explicitly).
67
+ if (IS_DEV && options.origin === undefined) {
68
+ try {
69
+ process.emitWarning(
70
+ `ingenium.ws: WebSocket route "${path}" registered without an \`origin\` option. ` +
71
+ 'WS handlers run outside the middleware pipeline and the browser sends the ' +
72
+ "user's cookies on cross-origin upgrades — restrict the Origin (e.g. " +
73
+ '`{ origin: true }` for same-origin) or authenticate inside the handler to ' +
74
+ 'prevent Cross-Site WebSocket Hijacking (CSWSH).',
75
+ )
76
+ } catch { /* worker runtimes can throw on emitWarning */ }
77
+ }
56
78
  routes.set(path, { path, handler, options })
57
79
  }
58
80
 
@@ -79,6 +101,19 @@ export function createWebSocketRegistrar(): WsRegistrar {
79
101
  return
80
102
  }
81
103
 
104
+ // CSWSH defense: enforce the Origin policy BEFORE `handleUpgrade`, so a
105
+ // rejected cross-origin request never completes the handshake. We reply
106
+ // with a real `403` handshake response (not a bare destroy) so the
107
+ // browser surfaces the rejection rather than a generic socket error.
108
+ if (route.options.origin !== undefined) {
109
+ const origin = req.headers.origin
110
+ if (!isOriginAllowed(route.options.origin, origin, req)) {
111
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
112
+ socket.destroy()
113
+ return
114
+ }
115
+ }
116
+
82
117
  // Lazy-load `ws`. On the first upgrade, dynamically import. If `ws`
83
118
  // isn't installed, give a clear actionable error and tear the socket
84
119
  // down — apps that wired `app.ws(...)` without installing the peer
@@ -160,6 +195,41 @@ export function createWebSocketRegistrar(): WsRegistrar {
160
195
  return { add, attach, close }
161
196
  }
162
197
 
198
+ /**
199
+ * Decide whether an upgrade Origin passes the configured policy. Centralized
200
+ * so the listener stays readable and the boolean/string/array/function arms
201
+ * are tested in one place.
202
+ *
203
+ * `true` means same-origin: the `Origin` URL's host (incl. port) must match
204
+ * the request `Host` header. A missing `Origin` (non-browser clients never
205
+ * send one) is rejected under `true` because we cannot prove same-origin —
206
+ * browser-facing sockets are the threat model here; trusted backend clients
207
+ * should use an explicit allowlist or a custom verifier instead.
208
+ */
209
+ function isOriginAllowed(
210
+ policy: WebSocketOriginOption,
211
+ origin: string | undefined,
212
+ req: IncomingMessage,
213
+ ): boolean {
214
+ if (typeof policy === 'function') return policy(origin, req)
215
+
216
+ if (policy === false) return true // explicitly disabled — allow all
217
+ if (policy === true) {
218
+ if (origin === undefined) return false
219
+ let originHost: string
220
+ try {
221
+ originHost = new URL(origin).host
222
+ } catch {
223
+ return false // malformed Origin header
224
+ }
225
+ return originHost === req.headers.host
226
+ }
227
+
228
+ // string | string[] — exact allowlist match against the raw Origin header.
229
+ if (origin === undefined) return false
230
+ return Array.isArray(policy) ? policy.includes(origin) : policy === origin
231
+ }
232
+
163
233
  /**
164
234
  * Build a minimal `IngeniumContext` for a WebSocket handler. We don't run the
165
235
  * full request pipeline (no middleware, no decorators) because the upgrade