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.
- package/README.md +1 -1
- package/dist/index.cjs +268 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +105 -9
- package/dist/index.d.ts +105 -9
- package/dist/index.js +268 -44
- package/dist/index.js.map +1 -1
- package/package.json +23 -8
- package/src/app.ts +7 -1
- package/src/body/multipart.ts +9 -1
- package/src/cors/middleware.ts +58 -3
- package/src/cors/types.ts +6 -3
- package/src/csrf/middleware.ts +98 -13
- package/src/csrf/types.ts +22 -1
- package/src/idempotency/middleware.ts +78 -5
- package/src/index.ts +1 -1
- package/src/jwt/middleware.ts +74 -3
- package/src/jwt/types.ts +12 -0
- package/src/jwt/verify.ts +75 -11
- package/src/problem/serialize.ts +18 -2
- package/src/proxy/trust.ts +22 -0
- package/src/session/middleware.ts +36 -3
- package/src/session/types.ts +14 -1
- package/src/static/middleware.ts +43 -8
- package/src/ws/index.ts +2 -0
- package/src/ws/middleware.ts +70 -0
- package/src/ws/types.ts +37 -0
package/src/jwt/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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) {
|
package/src/problem/serialize.ts
CHANGED
|
@@ -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
|
|
126
|
+
detail,
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
const instance = opts.instance(ctx)
|
package/src/proxy/trust.ts
CHANGED
|
@@ -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=/`.
|
|
252
|
-
*
|
|
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
|
|
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]
|
package/src/session/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
package/src/static/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/src/ws/middleware.ts
CHANGED
|
@@ -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
|