@nifrajs/middleware 3.5.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/ip-restriction.ts
CHANGED
|
@@ -1,97 +1,49 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
-
import type { Middleware } from "@nifrajs/core/server"
|
|
3
|
-
import {
|
|
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
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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.
|
|
156
|
-
* `clientIp
|
|
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
|
|
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: {
|
|
167
|
+
headers: {
|
|
168
|
+
"content-type": `multipart/${subtype}; boundary=${TOKEN_BOUNDARY.test(boundary) ? boundary : `"${boundary}"`}`,
|
|
169
|
+
},
|
|
165
170
|
})
|
|
166
171
|
}
|
package/src/pretty-json.ts
CHANGED
|
@@ -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
|
-
|
|
184
|
+
JSON.parse(text)
|
|
135
185
|
} catch {
|
|
136
186
|
return undefined
|
|
137
187
|
}
|
|
138
|
-
return `${
|
|
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
|
-
|
|
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(`${
|
|
236
|
+
return new Response(`${reindentJson(peeked.text, spaces)}${newline ? "\n" : ""}`, {
|
|
188
237
|
status: response.status,
|
|
189
238
|
statusText: response.statusText,
|
|
190
239
|
headers,
|
package/src/problem-details.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 {
|
|
3
|
-
|
|
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.
|
|
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
|
-
*
|
|
151
|
-
* client consume the quota for everyone. Enable only for intentional
|
|
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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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 ??
|
|
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(
|
|
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
|
package/src/security-headers.ts
CHANGED
|
@@ -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:
|
|
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}"`,
|