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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ingenium",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Express DX, Hono/Fastify throughput. A Node.js HTTP framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -13,7 +13,12 @@
13
13
  "require": "./dist/index.cjs"
14
14
  }
15
15
  },
16
- "files": ["dist", "src", "README.md", "LICENSE"],
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
17
22
  "scripts": {
18
23
  "build": "tsup",
19
24
  "typecheck": "tsc --noEmit",
@@ -22,22 +27,32 @@
22
27
  "engines": {
23
28
  "node": ">=20.0.0"
24
29
  },
25
- "keywords": ["http", "express", "framework", "router", "fast"],
30
+ "keywords": [
31
+ "http",
32
+ "express",
33
+ "framework",
34
+ "router",
35
+ "fast"
36
+ ],
26
37
  "license": "MIT",
27
38
  "repository": {
28
39
  "type": "git",
29
- "url": "git+https://github.com/Contra-Collective/ingenium.git",
40
+ "url": "git+https://github.com/IngeniumFramework/Ingenium.git",
30
41
  "directory": "packages/ingenium"
31
42
  },
32
- "homepage": "https://github.com/Contra-Collective/ingenium#readme",
33
- "bugs": "https://github.com/Contra-Collective/ingenium/issues",
43
+ "homepage": "https://github.com/IngeniumFramework/Ingenium#readme",
44
+ "bugs": "https://github.com/IngeniumFramework/Ingenium/issues",
34
45
  "peerDependencies": {
35
46
  "zod": "^3.23.0",
36
47
  "ws": "^8.18.0"
37
48
  },
38
49
  "peerDependenciesMeta": {
39
- "zod": { "optional": true },
40
- "ws": { "optional": true }
50
+ "zod": {
51
+ "optional": true
52
+ },
53
+ "ws": {
54
+ "optional": true
55
+ }
41
56
  },
42
57
  "devDependencies": {
43
58
  "zod": "^3.23.0",
package/src/app.ts CHANGED
@@ -1041,8 +1041,14 @@ export class IngeniumApp implements PluginTarget {
1041
1041
  throw miss
1042
1042
  }
1043
1043
  const applicable: IngeniumMiddleware[] = [...flat.globalMiddleware]
1044
+ // Collapse runs of '/' before the prefix check so a non-normalized request
1045
+ // path (e.g. `//admin/x`) can't slip past a deny-by-default / audit / security
1046
+ // gate scoped to `/admin`: `pathStartsWith('//admin/x', '/admin')` is false.
1047
+ // Matched routes bake their chain at compose time, so this only matters on the
1048
+ // trie-miss fallback. Local-only — ctx.path is left untouched.
1049
+ const normalizedPath = ctx.path.includes('//') ? ctx.path.replace(/\/{2,}/g, '/') : ctx.path
1044
1050
  for (const scoped of flat.scopedMiddleware) {
1045
- if (pathStartsWith(ctx.path, scoped.prefix)) applicable.push(scoped.mw)
1051
+ if (pathStartsWith(normalizedPath, scoped.prefix)) applicable.push(scoped.mw)
1046
1052
  }
1047
1053
  if (applicable.length === 0) {
1048
1054
  throw miss
@@ -165,7 +165,15 @@ export function parseMultipart(
165
165
  const allowed = opts.allowedMimePrefixes
166
166
 
167
167
  const boundary = extractBoundary(contentType)
168
- const result: MultipartResult = { fields: {}, files: {} }
168
+ // Use null-prototype maps so an attacker-chosen part name (`__proto__`,
169
+ // `constructor`, `prototype`) is stored as plain own-data instead of tripping
170
+ // the `__proto__` setter — which would otherwise reparent the maps' prototype
171
+ // and corrupt the membership/lookup logic in `appendField`. Mirrors the query
172
+ // parser's `toShallowArrayObject` in context.ts.
173
+ const result: MultipartResult = {
174
+ fields: Object.create(null),
175
+ files: Object.create(null),
176
+ }
169
177
 
170
178
  // Empty body → empty result. (No boundary delimiter at all.)
171
179
  if (buffer.length === 0) return result
@@ -1,6 +1,9 @@
1
1
  import type { IngeniumMiddleware } from '../middleware/types.ts'
2
2
  import type { IngeniumContext } from '../context/context.ts'
3
3
  import type { CorsOptions, CorsOrigin } from './types.ts'
4
+ import { IngeniumError } from '../errors.ts'
5
+
6
+ const IS_DEV = process.env.NODE_ENV !== 'production'
4
7
 
5
8
  const DEFAULT_METHODS: readonly string[] = [
6
9
  'GET',
@@ -52,7 +55,16 @@ async function resolveOrigin(
52
55
  return { value: null, reflected: false }
53
56
  }
54
57
 
55
- if (spec === true) return { value: reqOrigin, reflected: true }
58
+ // `origin: true` reflects whatever the request sends except the literal
59
+ // string "null". Browsers send `Origin: null` for sandboxed iframes, `file://`
60
+ // pages, and redirected/data-URL contexts; reflecting it back hands those
61
+ // untrusted, un-attributable contexts a same-origin-equivalent grant. It is
62
+ // only honoured when an explicit allowlist array opts into it (handled below).
63
+ if (spec === true) {
64
+ return reqOrigin === 'null'
65
+ ? { value: null, reflected: true }
66
+ : { value: reqOrigin, reflected: true }
67
+ }
56
68
 
57
69
  if (typeof spec === 'string') {
58
70
  return spec === reqOrigin
@@ -107,12 +119,55 @@ export function corsMiddleware(opts: CorsOptions = {}): IngeniumMiddleware {
107
119
  // Construction-time validation: `credentials: true` + wildcard origin is
108
120
  // forbidden by the CORS spec — browsers reject the response.
109
121
  if (credentials && origin === '*') {
110
- throw new Error(
122
+ throw new IngeniumError(
123
+ 500,
124
+ 'CORS_CREDENTIALS_WILDCARD',
111
125
  "ingenium.cors: `credentials: true` is incompatible with `origin: '*'`. " +
112
126
  'Specify an explicit origin (string, array, regex, or function) instead.',
113
127
  )
114
128
  }
115
129
 
130
+ // `credentials: true` + `origin: true` reflects *any* request Origin while
131
+ // setting `Access-Control-Allow-Credentials: true`. That is the classic
132
+ // credentialed-reflection vulnerability: any website can read authenticated
133
+ // responses. Unlike `origin: '*'` the browser does NOT reject this, so we must
134
+ // reject it ourselves at construction rather than silently shipping it.
135
+ if (credentials && origin === true) {
136
+ throw new IngeniumError(
137
+ 500,
138
+ 'CORS_CREDENTIALS_WILDCARD',
139
+ 'ingenium.cors: `credentials: true` is incompatible with `origin: true` ' +
140
+ '(reflecting any Origin with credentials lets any site read authenticated ' +
141
+ 'responses). Specify an explicit allowlist (string, array, regex, or function).',
142
+ )
143
+ }
144
+
145
+ // A function or RegExp origin combined with credentials can still reflect an
146
+ // untrusted Origin if the predicate is too permissive. We can't statically
147
+ // prove the predicate is safe, so warn (dev only) instead of throwing.
148
+ if (
149
+ IS_DEV &&
150
+ credentials &&
151
+ (typeof origin === 'function' || origin instanceof RegExp)
152
+ ) {
153
+ try {
154
+ process.emitWarning(
155
+ 'ingenium.cors: `credentials: true` with a function/RegExp origin reflects ' +
156
+ 'the request Origin when the predicate matches. Ensure it never matches ' +
157
+ 'untrusted origins, or you expose authenticated responses to them.',
158
+ { code: 'INGENIUM_CORS_CREDENTIALS_REFLECT' },
159
+ )
160
+ } catch {
161
+ // Worker runtimes can throw on emitWarning; diagnostics are best-effort.
162
+ }
163
+ }
164
+
165
+ // A function origin can return '*' for some requests and a specific origin
166
+ // for others. Without `Vary: Origin` a shared cache could serve one caller's
167
+ // allowed-origin response to a different origin (cache poisoning), so we force
168
+ // the header on every response when the origin is computed per-request.
169
+ const originIsFunction = typeof origin === 'function'
170
+
116
171
  const methodsHeader = methods.join(',')
117
172
  const exposedHeader = exposedHeaders && exposedHeaders.length > 0
118
173
  ? exposedHeaders.join(',')
@@ -132,7 +187,7 @@ export function corsMiddleware(opts: CorsOptions = {}): IngeniumMiddleware {
132
187
  ctx,
133
188
  )
134
189
 
135
- if (reflected) appendVary(ctx, 'Origin')
190
+ if (reflected || originIsFunction) appendVary(ctx, 'Origin')
136
191
 
137
192
  if (allowOrigin !== null) {
138
193
  ctx.set('access-control-allow-origin', allowOrigin)
package/src/cors/types.ts CHANGED
@@ -20,7 +20,9 @@ export type CorsOriginFn = (
20
20
  /**
21
21
  * Spec for the `origin` option.
22
22
  *
23
- * - `boolean` — `true` reflects any request `Origin`; `false` disables CORS.
23
+ * - `boolean` — `true` reflects any request `Origin` (but never the literal
24
+ * `"null"`, and rejected at construction when `credentials: true`); `false`
25
+ * disables CORS.
24
26
  * - `'*'` — wildcard: `Access-Control-Allow-Origin: *`.
25
27
  * - any other `string` — exact match against the request's `Origin`.
26
28
  * - `string[]` — allowlist; matched exactly.
@@ -62,8 +64,9 @@ export interface CorsOptions {
62
64
 
63
65
  /**
64
66
  * If `true`, sets `Access-Control-Allow-Credentials: true`.
65
- * Incompatible with `origin: '*'` — throws at construction time.
66
- * Default: `false`.
67
+ * Incompatible with `origin: '*'` and `origin: true` both throw at
68
+ * construction time, because reflecting credentials to an unrestricted set of
69
+ * origins lets any site read authenticated responses. Default: `false`.
67
70
  */
68
71
  credentials?: boolean
69
72
 
@@ -1,6 +1,6 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
2
  import { Buffer } from 'node:buffer'
3
- import { IngeniumError } from '../errors.ts'
3
+ import { IngeniumError, IngeniumHeaderInjectionError } 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 { CsrfCookieOptions, CsrfOptions, CsrfValueReader } from './types.ts'
@@ -12,6 +12,11 @@ export class IngeniumCsrfError extends IngeniumError {
12
12
  }
13
13
  }
14
14
 
15
+ const IS_DEV = process.env.NODE_ENV !== 'production'
16
+
17
+ /** CR/LF detector — config-supplied cookie attributes must not inject headers. */
18
+ const CRLF_RE = /[\r\n]/
19
+
15
20
  const TOKEN_BYTES = 18
16
21
  const SAFE_METHODS_DEFAULT: readonly string[] = ['GET', 'HEAD', 'OPTIONS', 'TRACE']
17
22
  const COOKIE_NAME_DEFAULT = 'ingenium.csrf'
@@ -24,6 +29,7 @@ interface ResolvedOptions {
24
29
  ignoreMethods: Set<string>
25
30
  value: CsrfValueReader
26
31
  skip: ((ctx: IngeniumContext) => boolean | Promise<boolean>) | null
32
+ sessionBinding: ((ctx: IngeniumContext) => string | undefined) | null
27
33
  }
28
34
 
29
35
  /**
@@ -55,11 +61,34 @@ export function csrfMiddleware(opts: CsrfOptions = {}): IngeniumMiddleware {
55
61
  return
56
62
  }
57
63
 
64
+ // Per-session binding (cookie mode only). When present, the token's
65
+ // signature covers this value so a token minted for one session can't be
66
+ // replayed against another. `undefined` falls back to plain double-submit.
67
+ const binding =
68
+ resolved.storage === 'cookie' && resolved.sessionBinding
69
+ ? resolved.sessionBinding(ctx)
70
+ : undefined
71
+ if (
72
+ IS_DEV &&
73
+ resolved.storage === 'cookie' &&
74
+ resolved.secrets.length > 0 &&
75
+ binding === undefined
76
+ ) {
77
+ try {
78
+ process.emitWarning(
79
+ 'csrfMiddleware: cookie-mode token has no session binding. Double-submit without binding assumes no XSS and a strict SameSite cookie — any token the server mints is globally valid. Provide `sessionBinding` to tie the token to a session/user.',
80
+ { type: 'IngeniumCsrfUnboundTokenWarning' },
81
+ )
82
+ } catch {
83
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
84
+ }
85
+ }
86
+
58
87
  // Resolve / mint the expected token for this request.
59
- let expected = readExpectedToken(ctx, resolved)
88
+ let expected = readExpectedToken(ctx, resolved, binding)
60
89
  let mintedThisRequest = false
61
90
  if (!expected) {
62
- expected = mintToken(resolved)
91
+ expected = mintToken(resolved, binding)
63
92
  mintedThisRequest = true
64
93
  }
65
94
 
@@ -88,16 +117,26 @@ export function csrfMiddleware(opts: CsrfOptions = {}): IngeniumMiddleware {
88
117
 
89
118
  // ───── Token mint / verify ─────────────────────────────────────────────────
90
119
 
91
- function mintToken(opts: ResolvedOptions): string {
120
+ function mintToken(opts: ResolvedOptions, binding: string | undefined): string {
92
121
  const raw = randomBytes(TOKEN_BYTES).toString('base64url')
93
122
  if (opts.storage === 'session' || opts.secrets.length === 0) return raw
94
123
  // Signed for double-submit so a forged cookie value can't pass verification.
95
- const sig = signToken(raw, opts.secrets[0]!)
124
+ // When a binding is present it's folded into the signature so the token is
125
+ // only valid for the session/user it was minted for.
126
+ const sig = signToken(raw, opts.secrets[0]!, binding)
96
127
  return `${raw}.${sig}`
97
128
  }
98
129
 
99
- function signToken(raw: string, secret: string): string {
100
- return createHmac('sha256', secret).update(raw).digest('base64url')
130
+ /**
131
+ * HMAC the token body. The binding (when present) is mixed into the signed
132
+ * message so the same `raw` value produces a different signature per session —
133
+ * a token can't be lifted from one user's cookie and replayed by another.
134
+ * The binding is never stored in the token; it's re-derived from the request
135
+ * at verify time, so it doesn't leak the session id to the client.
136
+ */
137
+ function signToken(raw: string, secret: string, binding?: string | undefined): string {
138
+ const message = binding === undefined ? raw : `${raw}.${binding}`
139
+ return createHmac('sha256', secret).update(message).digest('base64url')
101
140
  }
102
141
 
103
142
  function tokenMatches(submitted: string, expected: string): boolean {
@@ -108,13 +147,17 @@ function tokenMatches(submitted: string, expected: string): boolean {
108
147
  return timingSafeEqual(a, b)
109
148
  }
110
149
 
111
- function verifySignedToken(token: string, secrets: readonly string[]): boolean {
150
+ function verifySignedToken(
151
+ token: string,
152
+ secrets: readonly string[],
153
+ binding: string | undefined,
154
+ ): boolean {
112
155
  const dot = token.lastIndexOf('.')
113
156
  if (dot <= 0) return false
114
157
  const raw = token.slice(0, dot)
115
158
  const sig = token.slice(dot + 1)
116
159
  for (const secret of secrets) {
117
- const expected = signToken(raw, secret)
160
+ const expected = signToken(raw, secret, binding)
118
161
  if (expected.length !== sig.length) continue
119
162
  if (timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return true
120
163
  }
@@ -123,12 +166,19 @@ function verifySignedToken(token: string, secrets: readonly string[]): boolean {
123
166
 
124
167
  // ───── Storage ─────────────────────────────────────────────────────────────
125
168
 
126
- function readExpectedToken(ctx: IngeniumContext, opts: ResolvedOptions): string | null {
169
+ function readExpectedToken(
170
+ ctx: IngeniumContext,
171
+ opts: ResolvedOptions,
172
+ binding: string | undefined,
173
+ ): string | null {
127
174
  if (opts.storage === 'cookie') {
128
175
  const cookies = parseCookies(ctx.headers['cookie'])
129
176
  const token = cookies[opts.cookie.name]
130
177
  if (!token) return null
131
- if (opts.secrets.length > 0 && !verifySignedToken(token, opts.secrets)) return null
178
+ // Verify against the current request's binding: a token signed for another
179
+ // session won't match this request's binding and is rejected here, forcing
180
+ // a fresh mint rather than silently trusting a cross-session token.
181
+ if (opts.secrets.length > 0 && !verifySignedToken(token, opts.secrets, binding)) return null
132
182
  return token
133
183
  }
134
184
  // Session storage
@@ -161,6 +211,13 @@ function writeSession(ctx: IngeniumContext, token: string): void {
161
211
  }
162
212
 
163
213
  function appendSetCookie(ctx: IngeniumContext, value: string): void {
214
+ // This write goes straight to the header bag, bypassing ctx.set's guard, so
215
+ // re-assert the same CR/LF check here on the fully-composed value.
216
+ if (CRLF_RE.test(value)) {
217
+ throw new IngeniumHeaderInjectionError(
218
+ 'csrfMiddleware: Set-Cookie value contains CR/LF (possible header injection)',
219
+ )
220
+ }
164
221
  const existing = ctx._headers['set-cookie']
165
222
  if (!existing) {
166
223
  ctx._headers['set-cookie'] = [value]
@@ -205,13 +262,41 @@ function resolveOptions(opts: CsrfOptions): ResolvedOptions {
205
262
  path: opts.cookie?.path ?? '/',
206
263
  domain: opts.cookie?.domain ?? '',
207
264
  sameSite: opts.cookie?.sameSite ?? 'lax',
208
- secure: opts.cookie?.secure ?? false,
265
+ // Secure-by-default: a plaintext CSRF cookie is readable by a network
266
+ // attacker, who can then forge the double-submit header.
267
+ secure: opts.cookie?.secure ?? true,
209
268
  httpOnly: opts.cookie?.httpOnly ?? false,
210
269
  maxAgeSeconds: opts.cookie?.maxAgeSeconds ?? 7 * 24 * 60 * 60,
211
270
  }
271
+ // Config-supplied cookie attributes are interpolated raw into Set-Cookie;
272
+ // reject CR/LF here so they can't bypass the framework's header-injection
273
+ // guard (which the rest of the framework enforces via ctx.set).
274
+ if (CRLF_RE.test(cookie.path) || CRLF_RE.test(cookie.domain) || CRLF_RE.test(cookie.name)) {
275
+ throw new IngeniumHeaderInjectionError(
276
+ 'csrfMiddleware: cookie name/path/domain contains CR/LF (possible header injection)',
277
+ )
278
+ }
279
+ if (storage === 'cookie' && cookie.secure === false && IS_DEV) {
280
+ try {
281
+ process.emitWarning(
282
+ 'csrfMiddleware: CSRF cookie issued with Secure=false — the token is sent over plaintext HTTP and can be read by a network attacker. Only disable Secure for local HTTP development.',
283
+ { type: 'IngeniumCsrfInsecureCookieWarning' },
284
+ )
285
+ } catch {
286
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
287
+ }
288
+ }
212
289
  const ignoreMethods = new Set((opts.ignoreMethods ?? SAFE_METHODS_DEFAULT).map((m) => m.toUpperCase()))
213
290
  const value = opts.value ?? defaultValueReader
214
- return { secrets, storage, cookie, ignoreMethods, value, skip: opts.skip ?? null }
291
+ return {
292
+ secrets,
293
+ storage,
294
+ cookie,
295
+ ignoreMethods,
296
+ value,
297
+ skip: opts.skip ?? null,
298
+ sessionBinding: opts.sessionBinding ?? null,
299
+ }
215
300
  }
216
301
 
217
302
  const defaultValueReader: CsrfValueReader = (ctx) => {
package/src/csrf/types.ts CHANGED
@@ -24,7 +24,12 @@ export interface CsrfCookieOptions {
24
24
  domain?: string
25
25
  /** SameSite policy. Default `'lax'`. */
26
26
  sameSite?: 'lax' | 'strict' | 'none'
27
- /** Mark cookie Secure. Default `false`; set `true` behind TLS. */
27
+ /**
28
+ * Mark cookie Secure. **Default `true`** so the CSRF cookie is never sent
29
+ * over plaintext HTTP where a network attacker could read it and forge the
30
+ * double-submit header. Override to `false` only for local HTTP development;
31
+ * doing so emits a dev-mode warning.
32
+ */
28
33
  secure?: boolean
29
34
  /**
30
35
  * Mark cookie HttpOnly. **Default `false`** — clients must read the cookie
@@ -48,6 +53,22 @@ export interface CsrfOptions {
48
53
  storage?: CsrfStorage
49
54
  /** Cookie options when `storage === 'cookie'`. */
50
55
  cookie?: CsrfCookieOptions
56
+ /**
57
+ * Bind a cookie-mode token to a per-session/per-user identifier (e.g. a
58
+ * session id or authenticated user id derived from `ctx`).
59
+ *
60
+ * Without binding, a signed double-submit token is only proven to have been
61
+ * minted by *this server* — any token the server ever issued is globally
62
+ * valid for any user, so a leaked or shared token defeats the protection.
63
+ * When this returns a stable value, the token is HMAC'd over
64
+ * `raw + '.' + binding` and the binding is re-verified on unsafe requests,
65
+ * so a token minted for one session cannot be replayed against another.
66
+ *
67
+ * Returning `undefined` (e.g. before login) falls back to plain
68
+ * double-submit and emits a dev-mode warning. Has no effect in session
69
+ * storage mode, where the session id already authenticates the binding.
70
+ */
71
+ sessionBinding?: (ctx: IngeniumContext) => string | undefined
51
72
  /** Methods that bypass validation. Default `['GET', 'HEAD', 'OPTIONS', 'TRACE']`. */
52
73
  ignoreMethods?: readonly string[]
53
74
  /**
@@ -9,6 +9,12 @@ import type {
9
9
  ResolvedIdempotencyOptions,
10
10
  } from './types.ts'
11
11
 
12
+ /**
13
+ * Module-scope so V8 dead-code-eliminates the `if (IS_DEV)` diagnostic
14
+ * bodies in production builds. Read once at load — never per request.
15
+ */
16
+ const IS_DEV = process.env.NODE_ENV !== 'production'
17
+
12
18
  const DEFAULT_METHODS: readonly HttpMethod[] = ['POST', 'PATCH', 'DELETE']
13
19
 
14
20
  /**
@@ -17,14 +23,46 @@ const DEFAULT_METHODS: readonly HttpMethod[] = ['POST', 'PATCH', 'DELETE']
17
23
  */
18
24
  const DEFAULT_CACHEABLE = (status: number): boolean => status >= 200 && status < 500
19
25
 
20
- /** Authorization-header-derived scope; falls back to `'anon'`. */
21
- function defaultScope(ctx: IngeniumContext): string {
26
+ /**
27
+ * Sentinel returned by `defaultScope` for an unauthenticated request. A
28
+ * unique symbol (not the string `'anon'`) so the middleware can reliably
29
+ * distinguish "the framework couldn't isolate this client" from any string
30
+ * a user-supplied scope function might legitimately return. We must NOT
31
+ * cache across anonymous clients: the cache key is
32
+ * `<scope>:<method>:<path>:<key>`, so collapsing every anonymous caller to
33
+ * one constant lets client B reuse client A's idempotency key on the same
34
+ * route and be served A's full cached response — body AND Set-Cookie. IP
35
+ * is not a safe discriminator (shared NAT / spoofable), so we bypass.
36
+ */
37
+ const ANON_SCOPE = Symbol('ingenium.idempotency.anon')
38
+
39
+ /**
40
+ * Authorization-header-derived scope. Returns the `ANON_SCOPE` sentinel
41
+ * (not a string) when there is no Authorization header so the caller can
42
+ * detect the un-isolatable case and skip caching entirely.
43
+ */
44
+ function defaultScope(ctx: IngeniumContext): string | typeof ANON_SCOPE {
22
45
  const auth = ctx.headers['authorization']
23
46
  if (typeof auth === 'string' && auth.length > 0) return auth
24
47
  if (Array.isArray(auth) && auth.length > 0 && typeof auth[0] === 'string') return auth[0]
25
- return 'anon'
48
+ return ANON_SCOPE
26
49
  }
27
50
 
51
+ /**
52
+ * Response headers that carry per-client secrets/session state and must
53
+ * NEVER be replayed from a cached entry onto a different request. Even with
54
+ * correct scoping these are dangerous to copy verbatim; stripping them is
55
+ * defense-in-depth so a misconfigured scope can't leak another client's
56
+ * session cookie or bearer credential.
57
+ */
58
+ const SENSITIVE_REPLAY_HEADERS: readonly string[] = [
59
+ 'set-cookie',
60
+ 'authorization',
61
+ 'proxy-authorization',
62
+ 'www-authenticate',
63
+ 'proxy-authenticate',
64
+ ]
65
+
28
66
  /** Pull a header value as a single string (first element if it came as an array). */
29
67
  function readHeader(ctx: IngeniumContext, lowerName: string): string | undefined {
30
68
  const v = ctx.headers[lowerName]
@@ -76,6 +114,9 @@ function replay(ctx: IngeniumContext, cached: CachedResponse): void {
76
114
  for (const k of Object.keys(cached.headers)) {
77
115
  const v = cached.headers[k]
78
116
  if (v === undefined) continue
117
+ // Never replay per-client secrets onto a different request — a cached
118
+ // Set-Cookie / Authorization would hand one client another's session.
119
+ if (SENSITIVE_REPLAY_HEADERS.includes(k.toLowerCase())) continue
79
120
  ctx._headers[k] = Array.isArray(v) ? [...v] : v
80
121
  }
81
122
  ctx._headers['idempotent-replayed'] = 'true'
@@ -118,11 +159,17 @@ function replay(ctx: IngeniumContext, cached: CachedResponse): void {
118
159
  * }))
119
160
  */
120
161
  export function idempotencyMiddleware(opts: IdempotencyOptions = {}): IngeniumMiddleware {
162
+ // When the user supplies their own scope function we trust it to isolate
163
+ // clients and never bypass. Only the built-in `defaultScope` can yield the
164
+ // un-isolatable anonymous sentinel, so we capture whether it's in play.
165
+ const usingDefaultScope = opts.scope === undefined
166
+ const scopeFn: (ctx: IngeniumContext) => string | typeof ANON_SCOPE = opts.scope ?? defaultScope
167
+
121
168
  const resolved: ResolvedIdempotencyOptions = {
122
169
  header: (opts.header ?? 'Idempotency-Key').toLowerCase(),
123
170
  store: opts.store ?? new IdempotencyMemoryStore(),
124
171
  ttlMs: (opts.ttlSeconds ?? 86_400) * 1000,
125
- scope: opts.scope ?? defaultScope,
172
+ scope: scopeFn as (ctx: IngeniumContext) => string,
126
173
  methodSet: new Set(opts.methods ?? DEFAULT_METHODS),
127
174
  cacheable: opts.cacheable ?? DEFAULT_CACHEABLE,
128
175
  }
@@ -131,6 +178,10 @@ export function idempotencyMiddleware(opts: IdempotencyOptions = {}): IngeniumMi
131
178
  throw new Error('idempotency: ttlSeconds must be > 0')
132
179
  }
133
180
 
181
+ // Warn at most once per process — the bypass is correct but the developer
182
+ // almost certainly wants an explicit `scope` for unauthenticated routes.
183
+ let anonBypassWarned = false
184
+
134
185
  // Per-key in-flight map. The promise resolves once the first handler
135
186
  // finishes and its response has been snapshotted (or with `null` if the
136
187
  // response wasn't cacheable — second request then runs the handler).
@@ -146,7 +197,29 @@ export function idempotencyMiddleware(opts: IdempotencyOptions = {}): IngeniumMi
146
197
  return next()
147
198
  }
148
199
 
149
- const scope = resolved.scope(ctx)
200
+ const scope = scopeFn(ctx)
201
+
202
+ // Anonymous + default scope: there is no per-client discriminator, so
203
+ // caching here would serve one client's response (body + Set-Cookie) to
204
+ // another that happens to reuse the same Idempotency-Key on this route.
205
+ // Bypass entirely — run the handler without replay/store. (Only the
206
+ // built-in defaultScope can produce ANON_SCOPE; an explicit user scope
207
+ // never reaches this branch and keeps its prior behavior.)
208
+ if (scope === ANON_SCOPE) {
209
+ if (IS_DEV && usingDefaultScope && !anonBypassWarned) {
210
+ anonBypassWarned = true
211
+ try {
212
+ process.emitWarning(
213
+ 'idempotency: request to a cacheable route has no Authorization header, so the default scope cannot isolate clients. Idempotency caching was bypassed for this request to avoid serving one client the cached response of another. Supply an explicit `scope` function for unauthenticated endpoints.',
214
+ { type: 'IngeniumIdempotencyWarning' },
215
+ )
216
+ } catch {
217
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
218
+ }
219
+ }
220
+ return next()
221
+ }
222
+
150
223
  const cacheKey = `${scope}:${ctx.method}:${ctx.path}:${headerValue}`
151
224
 
152
225
  // 1. Persisted cache hit?
package/src/index.ts CHANGED
@@ -155,7 +155,7 @@ export type {
155
155
  } from './idempotency/types.ts'
156
156
 
157
157
  // ───── JWT middleware ──────────────────────────────────────────────────────
158
- export { jwtMiddleware } from './jwt/middleware.ts'
158
+ export { jwtMiddleware, IngeniumJwtKeyAlgMismatchError } from './jwt/middleware.ts'
159
159
  export { verifyJwt } from './jwt/verify.ts'
160
160
  export { fetchJwks, clearJwksCache } from './jwt/jwks.ts'
161
161
  export type {