@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
package/src/_utils.ts CHANGED
@@ -18,9 +18,32 @@ export function jsonError(
18
18
  )
19
19
  }
20
20
 
21
+ let guardInstances = 0
22
+
23
+ /**
24
+ * A guard's plugin name, unique to each instance. `use()` skips a name it already applied, so one
25
+ * shared name would silently drop a second, stricter guard (another `bearer()` before admin routes,
26
+ * a tighter `rateLimit()` inside a `group()`). The same instance applied twice still dedupes.
27
+ */
28
+ export function guardName(base: string): string {
29
+ guardInstances += 1
30
+ return `${base}#${guardInstances}`
31
+ }
32
+
21
33
  /** Shared with core so auth/CSRF middleware gets the allocation-light cookie scanner too. */
22
34
  export const parseCookies = parseCoreCookies
23
35
 
36
+ /**
37
+ * The credential of an `Authorization: Bearer <token>` header, or `null`. The auth-scheme is
38
+ * case-insensitive (RFC 9110 section 11.1), so `bearer x` and `BEARER x` carry a token too.
39
+ */
40
+ export function bearerToken(header: string | null | undefined): string | null {
41
+ if (header == null || header.length < 7 || header.slice(0, 7).toLowerCase() !== "bearer ") {
42
+ return null
43
+ }
44
+ return header.slice(7).trim() || null
45
+ }
46
+
24
47
  export function quotedHeaderValue(value: string): string {
25
48
  let out = ""
26
49
  for (let i = 0; i < value.length; i++) {
@@ -68,8 +91,22 @@ export function base64UrlEncode(bytes: ArrayBuffer | Uint8Array): string {
68
91
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
69
92
  }
70
93
 
94
+ const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
95
+
96
+ /**
97
+ * Decode unpadded base64url, rejecting non-canonical spellings. `atob` ignores the unused low bits of
98
+ * a final 2- or 3-character group, so without this check a JWT or CSRF signature has several textual
99
+ * forms that verify identically - the same rule core applies to signed cookies.
100
+ */
71
101
  export function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> | null {
72
102
  if (!/^[A-Za-z0-9_-]*$/.test(input) || input.length % 4 === 1) return null
103
+ const remainder = input.length % 4
104
+ if (remainder !== 0) {
105
+ const last = BASE64URL_ALPHABET.indexOf(input[input.length - 1]!)
106
+ if ((remainder === 2 && (last & 0x0f) !== 0) || (remainder === 3 && (last & 0x03) !== 0)) {
107
+ return null
108
+ }
109
+ }
73
110
  const padded = input
74
111
  .replace(/-/g, "+")
75
112
  .replace(/_/g, "/")
package/src/basic-auth.ts CHANGED
@@ -2,6 +2,7 @@ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
3
3
  import {
4
4
  decodeBase64,
5
+ guardName,
5
6
  jsonError,
6
7
  type MaybePromise,
7
8
  quotedHeaderValue,
@@ -87,7 +88,7 @@ export function basicAuth<P>(
87
88
  ? Promise.resolve(options.verify)
88
89
  : staticVerifier(options.username, options.password, options.principal ?? options.username)
89
90
 
90
- const plugin = defineIdentityPlugin("basicAuth", (app) =>
91
+ const plugin = defineIdentityPlugin(guardName("basicAuth"), (app) =>
91
92
  app.beforeHandle(async (c: { readonly req: Request }) => {
92
93
  const parsed = credentials(c.req)
93
94
  const principal =
package/src/body-limit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import { METHODS, type Middleware } from "@nifrajs/core/server"
3
- import { jsonError, SAFE_METHODS } from "./_utils.ts"
3
+ import { guardName, jsonError, SAFE_METHODS } from "./_utils.ts"
4
4
 
5
5
  export interface BodyLimitOptions {
6
6
  /** Maximum raw request-body bytes accepted. */
@@ -37,7 +37,7 @@ export function bodyLimit(options: BodyLimitOptions): Middleware {
37
37
  const allowLengthless = options.allowLengthless === true
38
38
 
39
39
  const middleware: Middleware = {
40
- name: "body-limit",
40
+ name: guardName("body-limit"),
41
41
  onRequest(req) {
42
42
  if (methods !== undefined ? !methods.has(req.method) : SAFE_METHODS.has(req.method)) {
43
43
  return undefined
package/src/cache.ts CHANGED
@@ -218,7 +218,7 @@ async function readBytesCapped(res: Response, maxBytes: number): Promise<ReadByt
218
218
  const bytes = concatBytes(chunks, total)
219
219
  return {
220
220
  bytes,
221
- response: new Response(bytes.byteLength === 0 ? null : bytes, {
221
+ response: new Response(bytes.byteLength === 0 ? null : bytes.slice(), {
222
222
  status: res.status,
223
223
  statusText: res.statusText,
224
224
  headers: res.headers,
@@ -11,8 +11,9 @@ export interface CompressionOptions {
11
11
  }
12
12
 
13
13
  // Text-like payloads benefit from gzip; binary media (images/video/archives) is already compressed.
14
+ // Not an event stream: gzip holds each event until its window fills, so none would arrive on time.
14
15
  const COMPRESSIBLE =
15
- /^(?:text\/|application\/(?:json|[\w.-]+\+json|javascript|xml|[\w.-]+\+xml|wasm|x-ndjson)|image\/svg\+xml)/i
16
+ /^(?:text\/(?!event-stream\b)|application\/(?:json|[\w.-]+\+json|javascript|xml|[\w.-]+\+xml|wasm|x-ndjson)|image\/svg\+xml)/i
16
17
 
17
18
  const defaultCompressible = (contentType: string): boolean => COMPRESSIBLE.test(contentType)
18
19
 
@@ -56,9 +57,18 @@ const gzipHeaders = (source: Headers): Headers => {
56
57
  headers.set("content-encoding", "gzip")
57
58
  headers.delete("content-length")
58
59
  addGzipVary(headers)
60
+ weakenEtag(headers)
59
61
  return headers
60
62
  }
61
63
 
64
+ /** A strong ETag names the identity bytes; the gzip bytes are another representation, so the tag is
65
+ * weakened (as nginx does) rather than shared by both - a cache or range request trusting it to
66
+ * mean byte-identical would otherwise splice one representation into the other. */
67
+ function weakenEtag(headers: ResponseHeadersView): void {
68
+ const etag = headers.get("etag")
69
+ if (etag !== null && !etag.startsWith("W/")) headers.set("etag", `W/${etag}`)
70
+ }
71
+
62
72
  function addGzipVary(headers: ResponseHeadersView): void {
63
73
  const vary = headers.get("vary")
64
74
  if (vary === null) {
@@ -140,7 +150,7 @@ async function compressRawBody(
140
150
  while (total < threshold) {
141
151
  const { done, value } = await reader.read()
142
152
  if (done) {
143
- return new Response(concat(buffered, total), {
153
+ return new Response(concat(buffered, total).slice(), {
144
154
  status: response.status,
145
155
  statusText: response.statusText,
146
156
  headers: response.headers,
@@ -250,6 +260,7 @@ export function compression(options: CompressionOptions = {}) {
250
260
  headers.set("content-encoding", "gzip")
251
261
  headers.delete("content-length")
252
262
  addGzipVary(headers)
263
+ weakenEtag(headers)
253
264
  return compressed
254
265
  })
255
266
  },
package/src/cors.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { withResponseObserver } from "@nifrajs/core/response-observer"
2
- import type { Middleware } from "@nifrajs/core/server"
2
+ import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
3
+ import { setNodeHeader } from "./_utils.ts"
4
+
5
+ const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe")
3
6
 
4
7
  export interface CorsOptions {
5
8
  /** Allowed origin(s): `"*"`, an exact origin, a list, or a predicate. Default `"*"`. */
@@ -38,6 +41,31 @@ function resolveAllowOrigin(
38
41
  return option === requestOrigin ? requestOrigin : null
39
42
  }
40
43
 
44
+ function nodeHeader(
45
+ headers: Readonly<Record<string, string | readonly string[]>> | undefined,
46
+ name: string,
47
+ headersAreLowercase: boolean | undefined,
48
+ ): string | null {
49
+ if (headers === undefined) return null
50
+ const direct = headers[name]
51
+ if (direct !== undefined) return typeof direct === "string" ? direct : direct.join(", ")
52
+ if (headersAreLowercase) return null
53
+ for (const key of Object.keys(headers)) {
54
+ if (key.toLowerCase() !== name) continue
55
+ const value = headers[key]
56
+ return value === undefined ? null : typeof value === "string" ? value : value.join(", ")
57
+ }
58
+ return null
59
+ }
60
+
61
+ function varyOnOrigin(value: string | null): string {
62
+ if (value === null || value.trim() === "") return "Origin"
63
+ const fields = value.split(",").map((field) => field.trim())
64
+ // `Vary: *` is the complete field value, not one member of a list.
65
+ if (fields.includes("*")) return "*"
66
+ return fields.some((field) => field.toLowerCase() === "origin") ? value : `${value}, Origin`
67
+ }
68
+
41
69
  /**
42
70
  * CORS as a {@link Middleware}. Preflight (`OPTIONS` + `Access-Control-Request-Method`)
43
71
  * short-circuits to `204` via `onRequest`; the origin/credentials headers are added in
@@ -59,7 +87,7 @@ export function cors(options: CorsOptions = {}): Middleware {
59
87
  const exposedHeaders = options.exposedHeaders?.join(", ")
60
88
  const maxAge = options.maxAge
61
89
 
62
- return withResponseObserver({
90
+ const middleware = withResponseObserver({
63
91
  name: "cors",
64
92
  onRequest(req) {
65
93
  const isPreflight =
@@ -86,15 +114,31 @@ export function cors(options: CorsOptions = {}): Middleware {
86
114
  return new Response(null, { status: 204, headers })
87
115
  },
88
116
  onResponseHeaders(headers, req) {
117
+ // Any policy other than `*` makes the answer depend on the request's Origin - including the
118
+ // answer that omits Allow-Origin. Without `Vary` on that one, a shared cache stores the
119
+ // header-less response and replays it to an allowed origin, breaking CORS for everyone.
120
+ if (origin !== "*") {
121
+ headers.set("vary", varyOnOrigin(headers.get("vary")))
122
+ }
89
123
  const allowOrigin = resolveAllowOrigin(origin, req.header("origin"))
90
124
  if (allowOrigin === null) return
91
125
  headers.set("access-control-allow-origin", allowOrigin)
92
- if (allowOrigin !== "*") {
93
- const vary = headers.get("vary")
94
- headers.set("vary", vary === null || vary === "" ? "Origin" : `${vary}, Origin`)
95
- }
96
126
  if (credentials) headers.set("access-control-allow-credentials", "true")
97
127
  if (exposedHeaders !== undefined) headers.set("access-control-expose-headers", exposedHeaders)
98
128
  },
99
- })
129
+ onNodeResponseHeaders(response: NodeResponseContext, req: NodeRequestContext) {
130
+ if (origin !== "*") {
131
+ const vary = nodeHeader(response.headers, "vary", response.headersAreLowercase)
132
+ setNodeHeader(response, "vary", varyOnOrigin(vary))
133
+ }
134
+ const allowOrigin = resolveAllowOrigin(origin, req.header("origin"))
135
+ if (allowOrigin === null) return
136
+ setNodeHeader(response, "access-control-allow-origin", allowOrigin)
137
+ if (credentials) setNodeHeader(response, "access-control-allow-credentials", "true")
138
+ if (exposedHeaders !== undefined)
139
+ setNodeHeader(response, "access-control-expose-headers", exposedHeaders)
140
+ },
141
+ } satisfies Middleware)
142
+ Object.defineProperty(middleware, BUN_NATIVE_REQUEST_SAFE, { value: true })
143
+ return middleware
100
144
  }
package/src/csrf.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import { METHODS, type Middleware } from "@nifrajs/core/server"
2
+ import { isSameOriginRequest, METHODS, type Middleware } from "@nifrajs/core/server"
3
3
  import {
4
4
  base64UrlEncode,
5
+ guardName,
5
6
  hmacSha256,
6
7
  jsonError,
7
8
  parseCookies,
@@ -35,9 +36,27 @@ export interface CsrfOptions {
35
36
  readonly cookie?: string
36
37
  /** Header carrying the same signed token. Default `"x-csrf-token"`. */
37
38
  readonly header?: string
39
+ /**
40
+ * Form field that may carry the token when the header is absent, so a plain HTML `<form>` (no
41
+ * script to set a header) can submit - e.g. `field: "_csrf"` with
42
+ * `<input type="hidden" name="_csrf" value="...">`. Read from `application/x-www-form-urlencoded`
43
+ * and `multipart/form-data` bodies only, through a clone, so the handler still reads the body.
44
+ * Unset (default), only the header is accepted.
45
+ *
46
+ * A cross-site form can set any field but never a custom header, so keep `checkOrigin` on with a
47
+ * field: the Origin check is what stops a site that can plant a cookie on your domain (a sibling
48
+ * subdomain) from pairing it with a matching field.
49
+ */
50
+ readonly field?: string
51
+ /**
52
+ * Largest body scanned for {@link field}, in bytes. Default 64 KiB. A bigger form (a file upload)
53
+ * fails closed - send the header, or raise this for that app.
54
+ */
55
+ readonly fieldMaxBytes?: number
38
56
  /** Unsafe methods to protect. Default: every method except GET/HEAD/OPTIONS/TRACE. */
39
57
  readonly methods?: readonly string[]
40
- /** Allowed request origins. Default: same-origin derived from the request URL. */
58
+ /** Allowed request origins. Default: same host as the request URL, where an `https:` Origin may
59
+ * reach an `http:` URL (what a TLS-terminating proxy looks like from the server). */
41
60
  readonly origins?: readonly string[]
42
61
  /** Check Origin/Referer on protected requests. Default true. */
43
62
  readonly checkOrigin?: boolean
@@ -49,20 +68,95 @@ function protectedMethod(method: string, configured: Set<string> | undefined): b
49
68
  return configured !== undefined ? configured.has(method) : !SAFE_METHODS.has(method)
50
69
  }
51
70
 
71
+ /**
72
+ * An explicit allowlist compares exact origins. The same-origin default goes through core's
73
+ * {@link isSameOriginRequest} instead of comparing against `new URL(req.url).origin`: behind a
74
+ * TLS-terminating proxy the request URL says `http:` while the browser reports `https:`, and an exact
75
+ * compare rejected every protected request there.
76
+ */
52
77
  function originAllowed(req: Request, origins: Set<string> | undefined): boolean {
53
- const allowed = origins ?? new Set([new URL(req.url).origin])
78
+ const allows = (candidate: string): boolean =>
79
+ origins !== undefined ? origins.has(candidate) : isSameOriginRequest(candidate, req)
54
80
  const origin = req.headers.get("origin")
55
- if (origin !== null) return allowed.has(origin)
81
+ if (origin !== null) return allows(origin)
56
82
 
57
83
  const referer = req.headers.get("referer")
58
84
  if (referer === null) return false
59
85
  try {
60
- return allowed.has(new URL(referer).origin)
86
+ return allows(new URL(referer).origin)
61
87
  } catch {
62
88
  return false
63
89
  }
64
90
  }
65
91
 
92
+ function mediaEssence(contentType: string): string {
93
+ const semi = contentType.indexOf(";")
94
+ return (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
95
+ }
96
+
97
+ /** Up to `maxBytes` of a request clone, or `null` when it is longer (or fails mid-read). */
98
+ async function readAtMost(req: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer> | null> {
99
+ const body = req.clone().body
100
+ if (body === null) return null
101
+ const reader = body.getReader()
102
+ const chunks: Uint8Array[] = []
103
+ let total = 0
104
+ try {
105
+ for (;;) {
106
+ const { done, value } = await reader.read()
107
+ if (done) break
108
+ total += value.byteLength
109
+ if (total > maxBytes) {
110
+ // A clone tees the request: cancel both branches on terminal rejection, without waiting
111
+ // for the unread branch or an upstream cleanup promise that may never settle.
112
+ void Promise.allSettled([reader.cancel(), req.body?.cancel()])
113
+ return null
114
+ }
115
+ chunks.push(value)
116
+ }
117
+ } catch {
118
+ return null
119
+ } finally {
120
+ reader.releaseLock()
121
+ }
122
+ const out = new Uint8Array(total)
123
+ let offset = 0
124
+ for (const chunk of chunks) {
125
+ out.set(chunk, offset)
126
+ offset += chunk.byteLength
127
+ }
128
+ return out
129
+ }
130
+
131
+ /**
132
+ * The token a form body carries in `field`, read from a clone so the handler keeps the original body.
133
+ * Anything unexpected - another media type, an oversized or unreadable body, a file part under the
134
+ * field's name - yields `null`, which the caller treats as a missing token.
135
+ */
136
+ async function formFieldToken(
137
+ req: Request,
138
+ field: string,
139
+ maxBytes: number,
140
+ ): Promise<string | null> {
141
+ if (req.body === null) return null
142
+ const contentType = req.headers.get("content-type") ?? ""
143
+ const essence = mediaEssence(contentType)
144
+ const urlencoded = essence === "application/x-www-form-urlencoded"
145
+ if (!urlencoded && essence !== "multipart/form-data") return null
146
+ const declared = req.headers.get("content-length")
147
+ if (declared !== null && !(Number(declared) <= maxBytes)) return null
148
+ const bytes = await readAtMost(req, maxBytes)
149
+ if (bytes === null) return null
150
+ try {
151
+ if (urlencoded) return new URLSearchParams(new TextDecoder().decode(bytes)).get(field)
152
+ const form = await new Response(bytes, { headers: { "content-type": contentType } }).formData()
153
+ const value = form.get(field)
154
+ return typeof value === "string" ? value : null
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
66
160
  export async function createCsrfToken(secret: CsrfSecret, nonce?: string): Promise<string> {
67
161
  const key = csrfKeys(secret)[0] as Uint8Array
68
162
  const tokenNonce = nonce ?? base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
@@ -93,27 +187,38 @@ export async function verifyCsrfToken(token: string, secret: CsrfSecret): Promis
93
187
 
94
188
  /**
95
189
  * Signed double-submit CSRF protection. A protected request must carry the same signed token in a
96
- * cookie and a header, and must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
190
+ * cookie and in a header (or, for a plain HTML form, the {@link CsrfOptions.field} form field), and
191
+ * must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
97
192
  */
98
193
  export function csrf(options: CsrfOptions): Middleware {
99
194
  const keys = csrfKeys(options.secret)
100
195
  const cookie = options.cookie ?? "csrf-token"
101
196
  const header = (options.header ?? "x-csrf-token").toLowerCase()
197
+ const field = options.field
198
+ if (field !== undefined && field.trim() === "") throw new Error("csrf: field must not be empty")
199
+ const fieldMaxBytes = options.fieldMaxBytes ?? 64 * 1024
200
+ if (!Number.isInteger(fieldMaxBytes) || fieldMaxBytes < 1) {
201
+ throw new Error("csrf: fieldMaxBytes must be a positive integer")
202
+ }
102
203
  const methods =
103
204
  options.methods !== undefined ? new Set(options.methods.map((m) => m.toUpperCase())) : undefined
104
205
  const origins = options.origins !== undefined ? new Set(options.origins) : undefined
105
206
  const checkOrigin = options.checkOrigin !== false
106
207
 
107
208
  const middleware: Middleware = {
108
- name: "csrf",
209
+ name: guardName("csrf"),
109
210
  async onRequest(req) {
110
211
  if (!protectedMethod(req.method, methods)) return undefined
111
212
  if (checkOrigin && !originAllowed(req, origins)) return jsonError(403, "csrf_failed")
112
213
 
113
214
  const cookieToken = parseCookies(req.headers.get("cookie"))[cookie]
114
- const headerToken = req.headers.get(header)
115
- if (cookieToken === undefined || headerToken === null) return jsonError(403, "csrf_failed")
116
- if (!(await timingSafeEqualString(cookieToken, headerToken))) {
215
+ if (cookieToken === undefined) return jsonError(403, "csrf_failed")
216
+ // The header wins; the body is only read (from a clone) when a form had no way to set one.
217
+ const requestToken =
218
+ req.headers.get(header) ??
219
+ (field === undefined ? null : await formFieldToken(req, field, fieldMaxBytes))
220
+ if (requestToken === null) return jsonError(403, "csrf_failed")
221
+ if (!(await timingSafeEqualString(cookieToken, requestToken))) {
117
222
  return jsonError(403, "csrf_failed")
118
223
  }
119
224
  return (await verifyCsrfToken(cookieToken, keys)) ? undefined : jsonError(403, "csrf_failed")
package/src/etag.ts CHANGED
@@ -137,6 +137,16 @@ export function etag(options: ETagOptions = {}) {
137
137
  if (req.method !== "GET" || response.status !== 200 || response.body === null) {
138
138
  return response
139
139
  }
140
+ // Hashing reads the body before any of it is sent. A stream meant to arrive as it is
141
+ // produced - an event stream, or one marked no-store or no-transform - passes through.
142
+ if (
143
+ /^text\/event-stream\b/i.test(response.headers.get("content-type") ?? "") ||
144
+ /(?:^|,)\s*(?:no-store|no-transform)\s*(?:,|$)/i.test(
145
+ response.headers.get("cache-control") ?? "",
146
+ )
147
+ ) {
148
+ return response
149
+ }
140
150
  const declared = response.headers.get("content-length")
141
151
  if (
142
152
  declared !== null &&
@@ -173,7 +183,7 @@ async function etagRawResponse(
173
183
  // The body was consumed and rebuilt from bytes. Re-derive framing at the serving adapter
174
184
  // instead of trusting a stale upstream declaration.
175
185
  headers.delete("content-length")
176
- return new Response(read.bytes, {
186
+ return new Response(read.bytes.slice(), {
177
187
  status: response.status,
178
188
  statusText: response.statusText,
179
189
  headers,
@@ -1,5 +1,5 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import { METHODS, type Middleware } from "@nifrajs/core/server"
2
+ import { METHODS, type Middleware, replacedRequestOf } from "@nifrajs/core/server"
3
3
 
4
4
  /**
5
5
  * Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
@@ -190,7 +190,11 @@ export interface IdempotencyOptions {
190
190
  readonly lockTtlMs?: number
191
191
  /** Max response bytes to cache. A larger response is returned but **not** stored. Default 1 MiB. */
192
192
  readonly maxBytes?: number
193
- /** Whether a response should be cached for replay. Default: status `< 500` (don't replay transient 5xx). */
193
+ /**
194
+ * Whether a response should be cached for replay. Default: any status below 500 except 401, 403,
195
+ * 408, 409, 425, and 429. Those, like a transient 5xx, say the operation did not run; replaying one
196
+ * would refuse every retry under that key until it expired.
197
+ */
194
198
  readonly shouldCache?: (response: Response) => boolean
195
199
  /**
196
200
  * Derive the store key from the request. Default: the `header` value scoped by method + path **and
@@ -219,6 +223,7 @@ export interface IdempotencyOptions {
219
223
  }
220
224
 
221
225
  const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
226
+ const NOT_THE_OPERATIONS_ANSWER = new Set([401, 403, 408, 409, 425, 429])
222
227
  const DAY_MS = 24 * 60 * 60 * 1000
223
228
 
224
229
  function assertPositiveTtl(value: number, name: string): void {
@@ -272,7 +277,8 @@ function responseWithBody(
272
277
  res: Response,
273
278
  body: Uint8Array | ReadableStream<Uint8Array> | null,
274
279
  ): Response {
275
- return new Response(body, {
280
+ const safeBody = body instanceof Uint8Array ? body.slice() : body
281
+ return new Response(safeBody, {
276
282
  status: res.status,
277
283
  statusText: res.statusText,
278
284
  headers: res.headers,
@@ -354,7 +360,8 @@ async function captureBody(
354
360
  function replay(record: IdempotencyRecord): Response {
355
361
  const headers = new Headers(record.headers as Array<[string, string]>)
356
362
  headers.set("idempotent-replayed", "true")
357
- return new Response(bodyFor(fromBase64(record.body)), { status: record.status, headers })
363
+ const body = bodyFor(fromBase64(record.body))
364
+ return new Response(body === null ? null : body.slice(), { status: record.status, headers })
358
365
  }
359
366
 
360
367
  const DEFAULT_PRINCIPAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const
@@ -403,6 +410,10 @@ function defaultIdempotencyKey(
403
410
  * session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
404
411
  * session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
405
412
  *
413
+ * The key is not bound to the request body: a key reused with a different body replays the first
414
+ * answer. A route that needs that check declares `schema.idempotency`, which fingerprints the request
415
+ * and refuses a mismatched reuse with 409.
416
+ *
406
417
  * Caching buffers the response body, so apply this to JSON/API routes, not streaming SSR responses.
407
418
  */
408
419
  export function idempotency(options: IdempotencyOptions): Middleware {
@@ -417,7 +428,9 @@ export function idempotency(options: IdempotencyOptions): Middleware {
417
428
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
418
429
  throw new Error("idempotency: maxBytes must be a non-negative integer")
419
430
  }
420
- const shouldCache = options.shouldCache ?? ((res: Response) => res.status < 500)
431
+ const shouldCache =
432
+ options.shouldCache ??
433
+ ((res: Response) => res.status < 500 && !NOT_THE_OPERATIONS_ANSWER.has(res.status))
421
434
  const principalHeaders = (options.principalHeaders ?? DEFAULT_PRINCIPAL_HEADERS).map((name) =>
422
435
  name.toLowerCase(),
423
436
  )
@@ -463,9 +476,16 @@ export function idempotency(options: IdempotencyOptions): Middleware {
463
476
  return undefined
464
477
  },
465
478
  async onResponse(res, req) {
466
- const claim = claimed.get(req)
467
- if (claim === undefined) return res // not a claimed request (safe method / no key / a replay)
468
- claimed.delete(req)
479
+ // A later onRequest hook (methodOverride, a transport codec) may have replaced the request
480
+ // this one claimed; walk back to it.
481
+ let claimedReq: Request | undefined = req
482
+ let claim = claimed.get(req)
483
+ while (claim === undefined && claimedReq !== undefined) {
484
+ claimedReq = replacedRequestOf(claimedReq)
485
+ if (claimedReq !== undefined) claim = claimed.get(claimedReq)
486
+ }
487
+ if (claim === undefined || claimedReq === undefined) return res // not a claimed request
488
+ claimed.delete(claimedReq)
469
489
  if (!shouldCache(res)) {
470
490
  await store.release(claim.key, claim.reservation)
471
491
  return res