@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.
- package/README.md +11 -6
- package/dist/_ip.d.ts +17 -0
- package/dist/_ip.d.ts.map +1 -0
- package/dist/_ip.js +91 -0
- package/dist/_ip.js.map +1 -0
- package/dist/_utils.d.ts +16 -0
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +33 -0
- package/dist/_utils.js.map +1 -1
- package/dist/basic-auth.d.ts.map +1 -1
- package/dist/basic-auth.js +2 -2
- package/dist/basic-auth.js.map +1 -1
- package/dist/body-limit.js +2 -2
- package/dist/body-limit.js.map +1 -1
- package/dist/cache.js +1 -1
- package/dist/cache.js.map +1 -1
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +13 -2
- package/dist/compression.js.map +1 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +50 -5
- package/dist/cors.js.map +1 -1
- package/dist/csrf.d.ts +21 -2
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +99 -10
- package/dist/csrf.js.map +1 -1
- package/dist/etag.d.ts.map +1 -1
- package/dist/etag.js +7 -1
- package/dist/etag.js.map +1 -1
- package/dist/idempotency.d.ts +9 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +24 -8
- package/dist/idempotency.js.map +1 -1
- package/dist/ip-restriction.d.ts +12 -7
- package/dist/ip-restriction.d.ts.map +1 -1
- package/dist/ip-restriction.js +25 -72
- package/dist/ip-restriction.js.map +1 -1
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +3 -3
- package/dist/jwt.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +4 -1
- package/dist/language.js.map +1 -1
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +6 -1
- package/dist/multipart.js.map +1 -1
- package/dist/pretty-json.d.ts.map +1 -1
- package/dist/pretty-json.js +63 -7
- package/dist/pretty-json.js.map +1 -1
- package/dist/problem-details.d.ts.map +1 -1
- package/dist/problem-details.js +8 -1
- package/dist/problem-details.js.map +1 -1
- package/dist/range.d.ts.map +1 -1
- package/dist/range.js +5 -2
- package/dist/range.js.map +1 -1
- package/dist/rate-limit.d.ts +11 -10
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +29 -19
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.d.ts +8 -1
- package/dist/request-id.d.ts.map +1 -1
- package/dist/request-id.js +6 -3
- package/dist/request-id.js.map +1 -1
- package/dist/security-headers.d.ts +4 -0
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +7 -1
- package/dist/security-headers.js.map +1 -1
- package/dist/token-auth.d.ts.map +1 -1
- package/dist/token-auth.js +3 -5
- package/dist/token-auth.js.map +1 -1
- package/package.json +6 -3
- package/src/_ip.ts +87 -0
- package/src/_utils.ts +37 -0
- package/src/basic-auth.ts +2 -1
- package/src/body-limit.ts +2 -2
- package/src/cache.ts +1 -1
- package/src/compression.ts +13 -2
- package/src/cors.ts +51 -7
- package/src/csrf.ts +115 -10
- package/src/etag.ts +11 -1
- package/src/idempotency.ts +28 -8
- package/src/ip-restriction.ts +38 -75
- package/src/jwt.ts +4 -2
- package/src/language.ts +5 -1
- package/src/multipart.ts +6 -1
- package/src/pretty-json.ts +56 -7
- package/src/problem-details.ts +8 -1
- package/src/range.ts +6 -3
- package/src/rate-limit.ts +48 -31
- package/src/request-id.ts +16 -4
- package/src/security-headers.ts +7 -1
- 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,
|
package/src/compression.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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,
|
package/src/idempotency.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|