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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ingenium",
|
|
3
|
-
"version": "0.0.
|
|
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": [
|
|
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": [
|
|
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/
|
|
40
|
+
"url": "git+https://github.com/IngeniumFramework/Ingenium.git",
|
|
30
41
|
"directory": "packages/ingenium"
|
|
31
42
|
},
|
|
32
|
-
"homepage": "https://github.com/
|
|
33
|
-
"bugs": "https://github.com/
|
|
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": {
|
|
40
|
-
|
|
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(
|
|
1051
|
+
if (pathStartsWith(normalizedPath, scoped.prefix)) applicable.push(scoped.mw)
|
|
1046
1052
|
}
|
|
1047
1053
|
if (applicable.length === 0) {
|
|
1048
1054
|
throw miss
|
package/src/body/multipart.ts
CHANGED
|
@@ -165,7 +165,15 @@ export function parseMultipart(
|
|
|
165
165
|
const allowed = opts.allowedMimePrefixes
|
|
166
166
|
|
|
167
167
|
const boundary = extractBoundary(contentType)
|
|
168
|
-
|
|
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
|
package/src/cors/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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: '*'` —
|
|
66
|
-
*
|
|
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
|
|
package/src/csrf/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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 {
|