@nifrajs/middleware 3.5.0 → 4.0.0

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.
Files changed (92) hide show
  1. package/README.md +11 -6
  2. package/dist/_ip.d.ts +17 -0
  3. package/dist/_ip.d.ts.map +1 -0
  4. package/dist/_ip.js +91 -0
  5. package/dist/_ip.js.map +1 -0
  6. package/dist/_utils.d.ts +16 -0
  7. package/dist/_utils.d.ts.map +1 -1
  8. package/dist/_utils.js +33 -0
  9. package/dist/_utils.js.map +1 -1
  10. package/dist/basic-auth.d.ts.map +1 -1
  11. package/dist/basic-auth.js +2 -2
  12. package/dist/basic-auth.js.map +1 -1
  13. package/dist/body-limit.js +2 -2
  14. package/dist/body-limit.js.map +1 -1
  15. package/dist/cache.js +1 -1
  16. package/dist/cache.js.map +1 -1
  17. package/dist/compression.d.ts.map +1 -1
  18. package/dist/compression.js +13 -2
  19. package/dist/compression.js.map +1 -1
  20. package/dist/cors.d.ts.map +1 -1
  21. package/dist/cors.js +50 -5
  22. package/dist/cors.js.map +1 -1
  23. package/dist/csrf.d.ts +21 -2
  24. package/dist/csrf.d.ts.map +1 -1
  25. package/dist/csrf.js +99 -10
  26. package/dist/csrf.js.map +1 -1
  27. package/dist/etag.d.ts.map +1 -1
  28. package/dist/etag.js +7 -1
  29. package/dist/etag.js.map +1 -1
  30. package/dist/idempotency.d.ts +9 -1
  31. package/dist/idempotency.d.ts.map +1 -1
  32. package/dist/idempotency.js +24 -8
  33. package/dist/idempotency.js.map +1 -1
  34. package/dist/ip-restriction.d.ts +12 -7
  35. package/dist/ip-restriction.d.ts.map +1 -1
  36. package/dist/ip-restriction.js +25 -72
  37. package/dist/ip-restriction.js.map +1 -1
  38. package/dist/jwt.d.ts.map +1 -1
  39. package/dist/jwt.js +3 -3
  40. package/dist/jwt.js.map +1 -1
  41. package/dist/language.d.ts.map +1 -1
  42. package/dist/language.js +4 -1
  43. package/dist/language.js.map +1 -1
  44. package/dist/multipart.d.ts.map +1 -1
  45. package/dist/multipart.js +6 -1
  46. package/dist/multipart.js.map +1 -1
  47. package/dist/pretty-json.d.ts.map +1 -1
  48. package/dist/pretty-json.js +63 -7
  49. package/dist/pretty-json.js.map +1 -1
  50. package/dist/problem-details.d.ts.map +1 -1
  51. package/dist/problem-details.js +8 -1
  52. package/dist/problem-details.js.map +1 -1
  53. package/dist/range.d.ts.map +1 -1
  54. package/dist/range.js +5 -2
  55. package/dist/range.js.map +1 -1
  56. package/dist/rate-limit.d.ts +11 -10
  57. package/dist/rate-limit.d.ts.map +1 -1
  58. package/dist/rate-limit.js +29 -19
  59. package/dist/rate-limit.js.map +1 -1
  60. package/dist/request-id.d.ts +8 -1
  61. package/dist/request-id.d.ts.map +1 -1
  62. package/dist/request-id.js +6 -3
  63. package/dist/request-id.js.map +1 -1
  64. package/dist/security-headers.d.ts +4 -0
  65. package/dist/security-headers.d.ts.map +1 -1
  66. package/dist/security-headers.js +7 -1
  67. package/dist/security-headers.js.map +1 -1
  68. package/dist/token-auth.d.ts.map +1 -1
  69. package/dist/token-auth.js +3 -5
  70. package/dist/token-auth.js.map +1 -1
  71. package/package.json +6 -3
  72. package/src/_ip.ts +87 -0
  73. package/src/_utils.ts +37 -0
  74. package/src/basic-auth.ts +2 -1
  75. package/src/body-limit.ts +2 -2
  76. package/src/cache.ts +1 -1
  77. package/src/compression.ts +13 -2
  78. package/src/cors.ts +51 -7
  79. package/src/csrf.ts +115 -10
  80. package/src/etag.ts +11 -1
  81. package/src/idempotency.ts +28 -8
  82. package/src/ip-restriction.ts +38 -75
  83. package/src/jwt.ts +4 -2
  84. package/src/language.ts +5 -1
  85. package/src/multipart.ts +6 -1
  86. package/src/pretty-json.ts +56 -7
  87. package/src/problem-details.ts +8 -1
  88. package/src/range.ts +6 -3
  89. package/src/rate-limit.ts +48 -31
  90. package/src/request-id.ts +16 -4
  91. package/src/security-headers.ts +7 -1
  92. package/src/token-auth.ts +3 -5
@@ -1,97 +1,49 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import type { Middleware } from "@nifrajs/core/server"
3
- import { jsonError, type MaybePromise } from "./_utils.ts"
2
+ import type { Middleware, Platform } from "@nifrajs/core/server"
3
+ import { type ParsedIp, parseIp } from "./_ip.ts"
4
+ import { guardName, jsonError, type MaybePromise } from "./_utils.ts"
4
5
 
5
6
  export type IpMatcher = string | ((ip: string, request: Request) => MaybePromise<boolean>)
6
7
 
7
8
  export interface IpRestrictionOptions {
8
9
  readonly allow?: readonly IpMatcher[]
9
10
  readonly deny?: readonly IpMatcher[]
10
- /** Preferred extraction hook when the adapter/app knows the peer address. */
11
- readonly clientIp?: (request: Request) => MaybePromise<string | null | undefined>
12
- /** Trusted proxy count for `X-Forwarded-For` extraction. Default: 0, so XFF is ignored. */
11
+ /**
12
+ * Custom extraction hook. Without it (and without `trustedProxies`/`header`), the caller is
13
+ * `platform.clientIp`: the socket peer, or the app's `clientIp` trust declaration applied to it.
14
+ */
15
+ readonly clientIp?: (
16
+ request: Request,
17
+ platform?: Platform,
18
+ ) => MaybePromise<string | null | undefined>
19
+ /** Trusted proxy count for `X-Forwarded-For` extraction. Default: 0, so XFF is ignored. Prefer the
20
+ * app-level `server({ clientIp: { trustedHops } })` declaration, which the default honors. */
13
21
  readonly trustedProxies?: number
14
22
  /** Exact trusted single-IP header, e.g. an infra-set `x-real-ip`. Not used unless configured. */
15
23
  readonly header?: string
16
24
  readonly error?: string
17
25
  }
18
26
 
19
- interface ParsedIp {
20
- readonly version: 4 | 6
21
- readonly value: bigint
22
- }
23
-
24
27
  interface Range {
25
28
  readonly version: 4 | 6
26
29
  readonly network: bigint
27
30
  readonly mask: bigint
28
31
  }
29
32
 
30
- function parseIPv4(input: string): bigint | null {
31
- const parts = input.split(".")
32
- if (parts.length !== 4) return null
33
- let out = 0n
34
- for (const part of parts) {
35
- if (!/^\d{1,3}$/.test(part)) return null
36
- const n = Number(part)
37
- if (n > 255) return null
38
- out = (out << 8n) | BigInt(n)
39
- }
40
- return out
41
- }
42
-
43
- function parseIPv6(input: string): bigint | null {
44
- if (input.includes("%")) return null
45
- let source = input.toLowerCase()
46
- if (source.includes(".")) {
47
- const lastColon = source.lastIndexOf(":")
48
- if (lastColon < 0) return null
49
- const ipv4 = parseIPv4(source.slice(lastColon + 1))
50
- if (ipv4 === null) return null
51
- const hi = Number((ipv4 >> 16n) & 0xffffn).toString(16)
52
- const lo = Number(ipv4 & 0xffffn).toString(16)
53
- source = `${source.slice(0, lastColon)}:${hi}:${lo}`
54
- }
55
-
56
- const halves = source.split("::")
57
- if (halves.length > 2) return null
58
- const left = halves[0] === "" ? [] : halves[0]!.split(":")
59
- const right = halves.length === 1 || halves[1] === "" ? [] : halves[1]!.split(":")
60
- const groups = [...left, ...right]
61
- if (groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null
62
-
63
- const missing = 8 - groups.length
64
- if (halves.length === 1) {
65
- if (missing !== 0) return null
66
- } else if (missing < 1) return null
67
-
68
- const expanded =
69
- halves.length === 1
70
- ? groups
71
- : [...left, ...Array.from({ length: missing }, () => "0"), ...right]
72
- let out = 0n
73
- for (const group of expanded) out = (out << 16n) | BigInt(Number.parseInt(group, 16))
74
- return out
75
- }
76
-
77
- function parseIp(input: string): ParsedIp | null {
78
- const trimmed = input.trim()
79
- const unbracketed =
80
- trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed
81
- const v4 = parseIPv4(unbracketed)
82
- if (v4 !== null) return { version: 4, value: v4 }
83
- const v6 = parseIPv6(unbracketed)
84
- return v6 === null ? null : { version: 6, value: v6 }
85
- }
86
-
87
33
  function parseRange(input: string): Range {
88
34
  const slash = input.indexOf("/")
89
35
  const address = slash < 0 ? input : input.slice(0, slash)
90
36
  const ip = parseIp(address)
91
37
  if (ip === null) throw new Error(`ipRestriction: invalid IP/CIDR ${JSON.stringify(input)}`)
92
38
  const bits = ip.version === 4 ? 32 : 128
39
+ // `::ffff:10.0.0.0/104` is matched as IPv4, so its prefix counts past the 96 mapping bits.
40
+ const mapped = ip.version === 4 && address.includes(":") ? 96 : 0
93
41
  const prefix =
94
- slash < 0 ? bits : /^\d+$/.test(input.slice(slash + 1)) ? Number(input.slice(slash + 1)) : -1
42
+ slash < 0
43
+ ? bits
44
+ : /^\d+$/.test(input.slice(slash + 1))
45
+ ? Number(input.slice(slash + 1)) - mapped
46
+ : -1
95
47
  if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits) {
96
48
  throw new Error(`ipRestriction: invalid CIDR prefix ${JSON.stringify(input)}`)
97
49
  }
@@ -114,10 +66,20 @@ function xForwardedClient(req: Request, trustedProxies: number): string | null {
114
66
 
115
67
  async function resolveClientIp(
116
68
  req: Request,
69
+ platform: Platform | undefined,
117
70
  options: IpRestrictionOptions,
118
71
  ): Promise<string | null> {
119
- const custom = await options.clientIp?.(req)
72
+ const custom = await options.clientIp?.(req, platform)
120
73
  if (custom !== undefined && custom !== null) return custom
74
+ // Explicit proxy options fail closed on their own: falling back to the socket peer there would
75
+ // judge the proxy's address whenever the forwarded header went missing.
76
+ if (
77
+ options.clientIp === undefined &&
78
+ (options.trustedProxies ?? 0) === 0 &&
79
+ options.header === undefined
80
+ ) {
81
+ return platform?.clientIp || null
82
+ }
121
83
  const fromXff = xForwardedClient(req, options.trustedProxies ?? 0)
122
84
  if (fromXff !== null) return fromXff
123
85
  if (options.header !== undefined) {
@@ -152,9 +114,10 @@ async function matches(
152
114
  }
153
115
 
154
116
  /**
155
- * IP allow/deny middleware. It fails closed when no trustworthy client IP can be derived. Configure
156
- * `clientIp`, `trustedProxies`, or a trusted single-IP `header`; unconfigured X-Forwarded-For is never
157
- * trusted.
117
+ * IP allow/deny middleware. It fails closed when no trustworthy client IP can be derived. By default the
118
+ * caller is the server-resolved `platform.clientIp` (socket peer, or the app's `clientIp` trust
119
+ * declaration); `clientIp`, `trustedProxies`, or a trusted single-IP `header` override it. Unconfigured
120
+ * X-Forwarded-For is never trusted.
158
121
  */
159
122
  export function ipRestriction(options: IpRestrictionOptions): Middleware {
160
123
  const trustedProxies = options.trustedProxies ?? 0
@@ -173,9 +136,9 @@ export function ipRestriction(options: IpRestrictionOptions): Middleware {
173
136
 
174
137
  return withRouteAssurance<Middleware>(
175
138
  {
176
- name: "ip-restriction",
177
- async onRequest(req) {
178
- const ipText = await resolveClientIp(req, options)
139
+ name: guardName("ip-restriction"),
140
+ async onRequest(req, platform) {
141
+ const ipText = await resolveClientIp(req, platform, options)
179
142
  if (ipText === null) return jsonError(403, error)
180
143
  const ip = parseIp(ipText)
181
144
  if (ip === null) return jsonError(403, error)
package/src/jwt.ts CHANGED
@@ -2,6 +2,8 @@ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
3
3
  import {
4
4
  base64UrlDecode,
5
+ bearerToken,
6
+ guardName,
5
7
  jsonError,
6
8
  type MaybePromise,
7
9
  parseCookies,
@@ -302,7 +304,7 @@ function tokenFromRequest(req: Request, header: string, cookie: string | undefin
302
304
  const headerValue = req.headers.get(header)
303
305
  if (headerValue !== null) {
304
306
  if (header === "authorization") {
305
- return headerValue.startsWith("Bearer ") ? headerValue.slice(7).trim() || null : null
307
+ return bearerToken(headerValue)
306
308
  }
307
309
  return headerValue.trim() || null
308
310
  }
@@ -324,7 +326,7 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
324
326
  const header = (options.header ?? "authorization").toLowerCase()
325
327
  const store = new WeakMap<Request, C>()
326
328
 
327
- const plugin = defineIdentityPlugin("jwt", (app) =>
329
+ const plugin = defineIdentityPlugin(guardName("jwt"), (app) =>
328
330
  app.beforeHandle(async (c: { readonly req: Request }) => {
329
331
  const token = tokenFromRequest(c.req, header, options.cookie)
330
332
  if (token === null) return optional ? undefined : reject(realm)
package/src/language.ts CHANGED
@@ -19,11 +19,15 @@ interface Range {
19
19
  readonly order: number
20
20
  }
21
21
 
22
+ // A browser sends a handful of ranges. The header is client-controlled, and every range costs a
23
+ // sort slot and a scan of the supported list, so only the first ones are read.
24
+ const MAX_RANGES = 32
25
+
22
26
  function parseAcceptLanguage(header: string | null): Range[] {
23
27
  if (header === null) return []
24
28
  const ranges: Range[] = []
25
29
  let order = 0
26
- for (const part of header.split(",")) {
30
+ for (const part of header.split(",", MAX_RANGES)) {
27
31
  const pieces = part.split(";").map((p) => p.trim())
28
32
  const tag = (pieces[0] ?? "").toLowerCase()
29
33
  if (tag === "") continue
package/src/multipart.ts CHANGED
@@ -27,6 +27,9 @@ export interface MultipartResponseOptions {
27
27
 
28
28
  const TEXT = new TextEncoder()
29
29
  const BOUNDARY = /^[A-Za-z0-9'()+_,./:=? -]{1,70}$/
30
+ // The boundary characters that are also header tokens. The rest - `( ) , / : = ?` and space - are
31
+ // delimiters in a Content-Type parameter, so a boundary holding one must be sent quoted.
32
+ const TOKEN_BOUNDARY = /^[A-Za-z0-9'+_.-]+$/
30
33
 
31
34
  interface ActiveReader {
32
35
  current: ReadableStreamDefaultReader<Uint8Array> | undefined
@@ -161,6 +164,8 @@ export function multipartResponse(
161
164
  })
162
165
  return new Response(stream, {
163
166
  status: options.status ?? 200,
164
- headers: { "content-type": `multipart/${subtype}; boundary=${boundary}` },
167
+ headers: {
168
+ "content-type": `multipart/${subtype}; boundary=${TOKEN_BOUNDARY.test(boundary) ? boundary : `"${boundary}"`}`,
169
+ },
165
170
  })
166
171
  }
@@ -89,6 +89,57 @@ async function peekText(res: Response, maxBytes: number): Promise<Peeked> {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Re-indent JSON text the way `JSON.stringify(value, null, spaces)` lays it out, without turning it
94
+ * into values: every string and number is copied as written. A parse/stringify round trip rounds an
95
+ * integer past 2^53 (`12345678901234567890` came back `12345678901234567000`), turns `1e400` into
96
+ * `null`, and rewrites escapes. `text` must already be known-valid JSON.
97
+ */
98
+ function reindentJson(text: string, spaces: number): string {
99
+ const unit = " ".repeat(spaces)
100
+ const pretty = spaces > 0
101
+ let out = ""
102
+ let depth = 0
103
+ const nextSignificant = (from: number): number => {
104
+ let at = from
105
+ while (at < text.length && isJsonWhitespace(text.charCodeAt(at))) at++
106
+ return at
107
+ }
108
+ for (let i = 0; i < text.length; i++) {
109
+ const code = text.charCodeAt(i)
110
+ if (isJsonWhitespace(code)) continue
111
+ if (code === 34 /* " */) {
112
+ let end = i + 1
113
+ while (text.charCodeAt(end) !== 34) end += text.charCodeAt(end) === 92 /* \ */ ? 2 : 1
114
+ out += text.slice(i, end + 1)
115
+ i = end
116
+ } else if (code === 123 /* { */ || code === 91 /* [ */) {
117
+ const close = nextSignificant(i + 1)
118
+ if (text.charCodeAt(close) === code + 2) {
119
+ out += code === 123 ? "{}" : "[]"
120
+ i = close
121
+ } else {
122
+ depth++
123
+ out += pretty ? `${text[i]}\n${unit.repeat(depth)}` : text[i]
124
+ }
125
+ } else if (code === 125 /* } */ || code === 93 /* ] */) {
126
+ depth--
127
+ out += pretty ? `\n${unit.repeat(depth)}${text[i]}` : text[i]
128
+ } else if (code === 44 /* , */) {
129
+ out += pretty ? `,\n${unit.repeat(depth)}` : ","
130
+ } else if (code === 58 /* : */) {
131
+ out += pretty ? ": " : ":"
132
+ } else {
133
+ out += text[i]
134
+ }
135
+ }
136
+ return out
137
+ }
138
+
139
+ function isJsonWhitespace(code: number): boolean {
140
+ return code === 32 || code === 9 || code === 10 || code === 13
141
+ }
142
+
92
143
  function requestView(request: Request): NodeRequestContext {
93
144
  return { method: request.method, url: request.url, header: (name) => request.headers.get(name) }
94
145
  }
@@ -129,13 +180,12 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
129
180
  ) {
130
181
  return undefined
131
182
  }
132
- let parsed: unknown
133
183
  try {
134
- parsed = JSON.parse(text)
184
+ JSON.parse(text)
135
185
  } catch {
136
186
  return undefined
137
187
  }
138
- return `${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`
188
+ return `${reindentJson(text, spaces)}${newline ? "\n" : ""}`
139
189
  },
140
190
  onResponseRaw(response, req) {
141
191
  if (!isEnabled(requestView(req))) return response
@@ -172,11 +222,10 @@ async function prettyRawResponse(
172
222
  ): Promise<Response> {
173
223
  const peeked = await peekText(response, maxBytes)
174
224
  if ("response" in peeked) return peeked.response
175
- let parsed: unknown
176
225
  try {
177
- parsed = JSON.parse(peeked.text)
226
+ JSON.parse(peeked.text)
178
227
  } catch {
179
- return new Response(peeked.bytes, {
228
+ return new Response(peeked.bytes.slice(), {
180
229
  status: response.status,
181
230
  statusText: response.statusText,
182
231
  headers: response.headers,
@@ -184,7 +233,7 @@ async function prettyRawResponse(
184
233
  }
185
234
  const headers = new Headers(response.headers)
186
235
  headers.delete("content-length")
187
- return new Response(`${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`, {
236
+ return new Response(`${reindentJson(peeked.text, spaces)}${newline ? "\n" : ""}`, {
188
237
  status: response.status,
189
238
  statusText: response.statusText,
190
239
  headers,
@@ -176,12 +176,19 @@ export function problemDetails(options: ProblemDetailsOptions = {}) {
176
176
  return JSON.stringify(document)
177
177
  },
178
178
  async onResponseRaw(response, req) {
179
+ // Only a JSON body can hold the envelope, so nothing else is read: reading waits for the
180
+ // body to arrive, which an error page streamed in pieces - or a stream that never ends -
181
+ // would turn into a held-back or hung response.
182
+ const contentType = response.headers.get("content-type")
183
+ const declared = Number(response.headers.get("content-length") ?? 0)
179
184
  if (
180
185
  response.status < 400 ||
181
186
  response.status >= 600 ||
182
187
  response.body === null ||
183
188
  response.headers.has("content-encoding") ||
184
- isAlreadyProblemDetails(response.headers.get("content-type"))
189
+ contentType === null ||
190
+ !JSON_TYPE.test(contentType) ||
191
+ declared > maxBytes
185
192
  ) {
186
193
  return response
187
194
  }
package/src/range.ts CHANGED
@@ -62,11 +62,14 @@ function ifRangeMatches(value: string | null, options: RangeResponseOptions): bo
62
62
  if (value === null) return true
63
63
  const item = value.trim()
64
64
  if (options.etag !== undefined) return !item.startsWith("W/") && item === options.etag
65
+ // A date validator must match Last-Modified exactly (RFC 9110 section 13.1.5), not merely be later:
66
+ // a representation replaced by one with an older timestamp would otherwise splice foreign bytes
67
+ // into a resumed download.
65
68
  if (options.lastModified === undefined) return false
66
69
  const time = Date.parse(item)
67
70
  return (
68
71
  Number.isFinite(time) &&
69
- Math.floor(time / 1000) >= Math.floor(options.lastModified.getTime() / 1000)
72
+ Math.floor(time / 1000) === Math.floor(options.lastModified.getTime() / 1000)
70
73
  )
71
74
  }
72
75
 
@@ -91,8 +94,8 @@ function shouldReturnNotModified(request: Request, options: RangeResponseOptions
91
94
  )
92
95
  }
93
96
 
94
- function responseBody(bytes: Uint8Array, request: Request): Uint8Array | null {
95
- return request.method === "HEAD" ? null : bytes
97
+ function responseBody(bytes: Uint8Array, request: Request): Uint8Array<ArrayBuffer> | null {
98
+ return request.method === "HEAD" ? null : new Uint8Array(bytes)
96
99
  }
97
100
 
98
101
  function multipartBytes(
package/src/rate-limit.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
3
- import { setNodeHeader, withHeaders } from "./_utils.ts"
2
+ import type {
3
+ Middleware,
4
+ NodeRequestContext,
5
+ NodeResponseContext,
6
+ Platform,
7
+ } from "@nifrajs/core/server"
8
+ import { ipBucket } from "./_ip.ts"
9
+ import { guardName, setNodeHeader, withHeaders } from "./_utils.ts"
4
10
 
5
11
  export interface RateLimitResult {
6
12
  /** Hits recorded in the current window, including this one. */
@@ -130,7 +136,8 @@ export interface RateLimitOptions {
130
136
  readonly windowMs: number
131
137
  /**
132
138
  * How many trusted reverse proxies sit in front of the app and append to `X-Forwarded-For`.
133
- * Default `0`.
139
+ * Default `0`. Prefer the app-level `server({ clientIp: { trustedHops } })` declaration, which the
140
+ * default key already honors; this option is for a limiter that must read XFF on its own.
134
141
  *
135
142
  * The default key reads the client IP from `X-Forwarded-For` as the address your **edge** proxy
136
143
  * observed - the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
@@ -140,34 +147,45 @@ export interface RateLimitOptions {
140
147
  * ⚠️ With the default `0`, `X-Forwarded-For` is treated as fully client-controlled and **ignored**.
141
148
  * Reading the
142
149
  * *first* XFF hop - the old behavior - let any client mint a fresh bucket per request and defeat the
143
- * limiter. Set `trustedProxies` (only safe behind a proxy you control that appends XFF), configure a
144
- * trusted single-IP {@link header}, or supply a custom {@link key} (e.g. an authenticated user id).
150
+ * limiter.
145
151
  */
146
152
  readonly trustedProxies?: number
147
153
  /** Exact trusted single-IP header, e.g. an infra-set `x-real-ip`. Not read unless configured. */
148
154
  readonly header?: string
149
155
  /**
150
- * Allow one shared bucket when no per-request key can be derived. Off by default because it lets one
151
- * client consume the quota for everyone. Enable only for intentional global throttles.
156
+ * One shared bucket for every request that {@link header}/{@link trustedProxies} cannot key. Off by
157
+ * default because it lets one client consume the quota for everyone. Enable only for intentional
158
+ * global throttles; with neither `header` nor `trustedProxies` set, every request shares the bucket.
152
159
  */
153
160
  readonly allowGlobalKey?: boolean
154
161
  /**
155
- * Bucket key for a request. Overrides the default XFF-based key entirely - set this for accurate
156
- * per-client limiting (e.g. an authenticated user id, or a header your proxy sets). A `Middleware`
157
- * can't see the socket IP (that needs the server instance).
162
+ * Bucket key for a request. Overrides the default key entirely - e.g. an authenticated user id.
163
+ * `platform.clientIp` is the caller the app's `clientIp` trust declaration derived (the socket peer
164
+ * when none is declared).
158
165
  */
159
- readonly key?: (req: Request) => string
166
+ readonly key?: (req: Request, platform?: Platform) => string
160
167
  }
161
168
 
169
+ /**
170
+ * The default bucket key. With nothing configured it is the caller IP the server resolved into
171
+ * `platform.clientIp` - the raw socket peer, or the app's `clientIp` trust declaration applied to the
172
+ * forwarding chain. `null` (no adapter-observed peer, e.g. a synthetic `app.fetch`) fails closed.
173
+ * Every IP-derived key goes through {@link ipBucket}: an IPv6 caller counts by its /64.
174
+ */
162
175
  function defaultKey(
163
176
  req: Request,
177
+ platform: Platform | undefined,
164
178
  trustedProxies: number,
165
179
  header: string | undefined,
166
180
  allowGlobalKey: boolean,
167
181
  ): string | null {
182
+ if (header === undefined && trustedProxies === 0 && !allowGlobalKey) {
183
+ const peer = platform?.clientIp
184
+ return peer ? ipBucket(peer) : null
185
+ }
168
186
  if (header !== undefined) {
169
187
  const ip = req.headers.get(header)
170
- if (ip !== null && ip.trim() !== "") return ip.trim()
188
+ if (ip !== null && ip.trim() !== "") return ipBucket(ip.trim())
171
189
  }
172
190
  if (trustedProxies > 0) {
173
191
  const xff = req.headers.get("x-forwarded-for")
@@ -177,7 +195,7 @@ function defaultKey(
177
195
  // proxy observed = the real client. A client can only prepend fakes further left, which this
178
196
  // index skips. A chain shorter than `trustedProxies` (misconfig) → undefined → fall through.
179
197
  const ip = parts[parts.length - trustedProxies]?.trim()
180
- if (ip !== undefined && ip !== "") return ip
198
+ if (ip !== undefined && ip !== "") return ipBucket(ip)
181
199
  }
182
200
  }
183
201
  // No trusted proxy (or XFF absent/too-short): XFF isn't trustworthy, so don't derive a per-client key
@@ -188,20 +206,25 @@ function defaultKey(
188
206
  /** {@link defaultKey} against the allocation-light native request view - same logic, same order. */
189
207
  function nativeKey(
190
208
  req: NodeRequestContext,
209
+ platform: Platform | undefined,
191
210
  trustedProxies: number,
192
211
  header: string | undefined,
193
212
  allowGlobalKey: boolean,
194
213
  ): string | null {
214
+ if (header === undefined && trustedProxies === 0 && !allowGlobalKey) {
215
+ const peer = platform?.clientIp
216
+ return peer ? ipBucket(peer) : null
217
+ }
195
218
  if (header !== undefined) {
196
219
  const ip = req.header(header)
197
- if (ip !== null && ip.trim() !== "") return ip.trim()
220
+ if (ip !== null && ip.trim() !== "") return ipBucket(ip.trim())
198
221
  }
199
222
  if (trustedProxies > 0) {
200
223
  const xff = req.header("x-forwarded-for")
201
224
  if (xff !== null) {
202
225
  const parts = xff.split(",")
203
226
  const ip = parts[parts.length - trustedProxies]?.trim()
204
- if (ip !== undefined && ip !== "") return ip
227
+ if (ip !== undefined && ip !== "") return ipBucket(ip)
205
228
  }
206
229
  }
207
230
  return allowGlobalKey ? "global" : null
@@ -226,18 +249,10 @@ export function rateLimit(options: RateLimitOptions): Middleware {
226
249
  const header = options.header?.trim().toLowerCase()
227
250
  if (header !== undefined && header.trim() === "") throw new Error("rateLimit: header is empty")
228
251
  const allowGlobalKey = options.allowGlobalKey === true
229
- if (
230
- options.key === undefined &&
231
- header === undefined &&
232
- trustedProxies === 0 &&
233
- !allowGlobalKey
234
- ) {
235
- throw new Error(
236
- "rateLimit: configure key, header, or trustedProxies; pass allowGlobalKey: true only for an intentional shared bucket",
237
- )
238
- }
239
252
  const keyOf =
240
- options.key ?? ((req: Request) => defaultKey(req, trustedProxies, header, allowGlobalKey))
253
+ options.key ??
254
+ ((req: Request, platform?: Platform) =>
255
+ defaultKey(req, platform, trustedProxies, header, allowGlobalKey))
241
256
  const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
242
257
  // State for the Node twins, keyed by the NodeRequestContext identity (the same object is passed
243
258
  // to the request and response twins - the core identity contract that replaces `Request` keying).
@@ -270,9 +285,9 @@ export function rateLimit(options: RateLimitOptions): Middleware {
270
285
  }
271
286
 
272
287
  const middleware: Middleware = {
273
- name: "rate-limit",
274
- async onRequest(req) {
275
- const outcome = await hitStore(keyOf(req))
288
+ name: guardName("rate-limit"),
289
+ async onRequest(req, platform) {
290
+ const outcome = await hitStore(keyOf(req, platform))
276
291
  if (outcome instanceof Response) return outcome
277
292
  quota.set(req, outcome.info)
278
293
  return outcome.reject
@@ -291,8 +306,10 @@ export function rateLimit(options: RateLimitOptions): Middleware {
291
306
  // takes a real `Request`, which is exactly the object the native lane avoids building.
292
307
  ...(options.key === undefined
293
308
  ? {
294
- onNodeRequest: async (req: NodeRequestContext) => {
295
- const outcome = await hitStore(nativeKey(req, trustedProxies, header, allowGlobalKey))
309
+ onNodeRequest: async (req: NodeRequestContext, platform?: Platform) => {
310
+ const outcome = await hitStore(
311
+ nativeKey(req, platform, trustedProxies, header, allowGlobalKey),
312
+ )
296
313
  if (outcome instanceof Response) return outcome
297
314
  nativeQuota.set(req, outcome.info)
298
315
  return outcome.reject
package/src/request-id.ts CHANGED
@@ -4,10 +4,19 @@ import { setNodeHeader, withHeaders } from "./_utils.ts"
4
4
  export interface RequestIdOptions {
5
5
  /** Header read for an inbound id (trace propagation) + echoed on the response. Default `"x-request-id"`. */
6
6
  readonly header?: string
7
- /** Generate an id when the inbound header is absent. Default `crypto.randomUUID()`. */
7
+ /** Generate an id when the inbound header is absent or not accepted. Default `crypto.randomUUID()`. */
8
8
  readonly generate?: () => string
9
+ /**
10
+ * Whether an inbound id is used. The id is client-supplied and lands in logs, traces, and the
11
+ * response, so the default accepts 1-200 visible ASCII characters - no spaces, no control
12
+ * characters (a terminal escape sequence), no unbounded length. A refused id is replaced by a
13
+ * generated one.
14
+ */
15
+ readonly accept?: (id: string) => boolean
9
16
  }
10
17
 
18
+ const INBOUND_ID = /^[\x21-\x7e]{1,200}$/
19
+
11
20
  /**
12
21
  * A {@link defineContextPlugin} plugin that gives every request a stable id: it reuses an inbound
13
22
  * `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
@@ -29,6 +38,9 @@ export function requestId(options: RequestIdOptions = {}): ContextPlugin<{ reque
29
38
  // the response twin's "already echoed?" probe and the derive's write must agree on it.
30
39
  const wireHeader = header.toLowerCase()
31
40
  const generate = options.generate ?? (() => crypto.randomUUID())
41
+ const acceptable = options.accept ?? ((id: string) => INBOUND_ID.test(id))
42
+ const inboundOf = (id: string | null): string | null =>
43
+ id !== null && acceptable(id) ? id : null
32
44
  // Web-lane derive-to-response pairing for GENERATED ids (an inbound one is re-readable from the
33
45
  // request in onResponse). Entries are deleted in onResponse; a Node-lane derive also writes here
34
46
  // but its id travels via the `c.set` write into the outcome record, and the weakly-held entry is
@@ -52,7 +64,7 @@ export function requestId(options: RequestIdOptions = {}): ContextPlugin<{ reque
52
64
  if (res.headers.get(header) !== null) return res
53
65
  // The id the derive generated; else the inbound header, then a fresh id - so a response
54
66
  // no derive saw (a 404, an earlier middleware's short-circuit) is still covered.
55
- const echo = id ?? req.headers.get(header) ?? generate()
67
+ const echo = id ?? inboundOf(req.headers.get(header)) ?? generate()
56
68
  return withHeaders(res, (headers) => {
57
69
  headers.set(header, echo)
58
70
  })
@@ -62,7 +74,7 @@ export function requestId(options: RequestIdOptions = {}): ContextPlugin<{ reque
62
74
  // exactly this one record-property check. Anything the derive never saw - a 404, a native
63
75
  // short-circuit - gets the inbound id or a fresh one here.
64
76
  if (res.headers?.[wireHeader] !== undefined) return
65
- setNodeHeader(res, wireHeader, req.header(header) ?? generate())
77
+ setNodeHeader(res, wireHeader, inboundOf(req.header(header)) ?? generate())
66
78
  },
67
79
  })
68
80
  .derive((c) => {
@@ -72,7 +84,7 @@ export function requestId(options: RequestIdOptions = {}): ContextPlugin<{ reque
72
84
  // carries it into a framework-rendered result on every lane - on Node-direct, into the
73
85
  // outcome record, where the response twin sees it as already echoed. Header and
74
86
  // `c.requestId` are therefore the same value everywhere with a single generation.
75
- const inbound = c.header(header)
87
+ const inbound = inboundOf(c.header(header))
76
88
  const id = inbound ?? generate()
77
89
  if (inbound === null) ids.set(c.req, id)
78
90
  c.set.headers[wireHeader] = id
@@ -36,6 +36,10 @@ export interface SecurityHeadersOptions {
36
36
  * Every value is fixed at construction, so these are declared statically rather than written by a
37
37
  * response hook: an app whose response middleware is only this keeps the fused native lanes. A route
38
38
  * that sets one of these names itself keeps its own value.
39
+ *
40
+ * A group can apply its own configuration over the app's: `group.use(securityHeaders({ ... }))`
41
+ * replaces the app's value of each header it sets, on the group's routes only. Applying the same
42
+ * configuration twice is a no-op.
39
43
  */
40
44
  export function securityHeaders(options: SecurityHeadersOptions = {}): Middleware {
41
45
  const frameOptions = options.frameOptions ?? "DENY"
@@ -72,7 +76,9 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
72
76
 
73
77
  return withRouteAssurance<Middleware>(
74
78
  {
75
- name: "security-headers",
79
+ // The configuration is part of the name: the same one applied twice dedupes, while a group's
80
+ // different configuration applies over its parent's instead of being skipped as a repeat.
81
+ name: `security-headers:${JSON.stringify(declared)}`,
76
82
  responseHeaders: declared,
77
83
  },
78
84
  {
package/src/token-auth.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
3
+ import { bearerToken, guardName } from "./_utils.ts"
3
4
 
4
5
  type MaybePromise<T> = T | Promise<T>
5
6
 
@@ -48,7 +49,7 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
48
49
  headers: config.challenge !== undefined ? { "www-authenticate": config.challenge } : {},
49
50
  },
50
51
  )
51
- const plugin = defineIdentityPlugin(config.name, (app) =>
52
+ const plugin = defineIdentityPlugin(guardName(config.name), (app) =>
52
53
  app.beforeHandle(async (c: { readonly req: Request }) => {
53
54
  const token = config.extract(c.req)
54
55
  // Empty string is treated as "no credential" - never passed to verify.
@@ -96,10 +97,7 @@ export function bearer<P>(options: BearerOptions<P>): AuthPlugin<P> {
96
97
  const realm = options.realm ?? "api"
97
98
  return createTokenAuth({
98
99
  name: "bearer",
99
- extract: (request) => {
100
- const header = request.headers.get("authorization")
101
- return header?.startsWith("Bearer ") === true ? header.slice(7).trim() : undefined
102
- },
100
+ extract: (request) => bearerToken(request.headers.get("authorization")) ?? undefined,
103
101
  verify: options.verify,
104
102
  optional: options.optional === true,
105
103
  challenge: `Bearer realm="${realm}"`,