@nifrajs/middleware 1.7.0 → 1.12.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/dist/admission.d.ts +92 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +218 -0
- package/dist/admission.js.map +1 -0
- package/dist/basic-auth.d.ts.map +1 -1
- package/dist/basic-auth.js +9 -1
- package/dist/basic-auth.js.map +1 -1
- package/dist/body-limit.d.ts +1 -1
- package/dist/body-limit.d.ts.map +1 -1
- package/dist/body-limit.js +15 -1
- package/dist/body-limit.js.map +1 -1
- package/dist/csrf.d.ts +1 -1
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +13 -1
- package/dist/csrf.js.map +1 -1
- package/dist/idempotency.d.ts +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +9 -1
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ip-restriction.d.ts.map +1 -1
- package/dist/ip-restriction.js +9 -3
- package/dist/ip-restriction.js.map +1 -1
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +9 -1
- package/dist/jwt.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +7 -1
- package/dist/rate-limit.js.map +1 -1
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +7 -2
- package/dist/security-headers.js.map +1 -1
- package/dist/token-auth.d.ts.map +1 -1
- package/dist/token-auth.js +9 -1
- package/dist/token-auth.js.map +1 -1
- package/package.json +3 -3
- package/src/admission.ts +329 -0
- package/src/basic-auth.ts +9 -1
- package/src/body-limit.ts +15 -2
- package/src/csrf.ts +14 -2
- package/src/idempotency.ts +9 -2
- package/src/index.ts +12 -0
- package/src/ip-restriction.ts +24 -13
- package/src/jwt.ts +9 -1
- package/src/rate-limit.ts +7 -1
- package/src/security-headers.ts +21 -13
- package/src/token-auth.ts +9 -1
package/src/admission.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { AdmissionController, AdmissionDecision } from "@nifrajs/core"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adaptive capacity admission. Rate limiting bounds request *frequency* and `@nifrajs/budget` bounds
|
|
5
|
+
* request *duration*; neither stops a healthy instance from accepting more *concurrent* work than it
|
|
6
|
+
* can finish. This gate admits on live capacity evidence — in-flight count + event-loop lag — briefly
|
|
7
|
+
* queues at the edge, and sheds the rest with `429` + `Retry-After`, so p99 stays bounded under load
|
|
8
|
+
* instead of collapsing.
|
|
9
|
+
*
|
|
10
|
+
* Public mechanics (in-flight + loop lag) know nothing about tenants. An application-supplied {@link AdmissionPolicy}
|
|
11
|
+
* hook layers tenant priority and reserved capacity on top without leaking
|
|
12
|
+
* those concerns into the OSS core.
|
|
13
|
+
*
|
|
14
|
+
* Wire it as the server's `admission` option (NOT an `onRequest` hook — a hook disables the native
|
|
15
|
+
* route table). Off by default: when unset, the request path is untouched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type ShedReason = "inflight" | "loop-lag" | "queue-timeout" | "policy" | "cancelled"
|
|
19
|
+
|
|
20
|
+
/** Pure capacity evidence handed to the policy hook. The mechanics never invent tenant concepts. */
|
|
21
|
+
export interface AdmissionEvidence {
|
|
22
|
+
readonly inFlight: number
|
|
23
|
+
readonly maxInFlight: number
|
|
24
|
+
readonly lagMs: number
|
|
25
|
+
readonly maxLagMs: number
|
|
26
|
+
readonly queued: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Application-supplied admission policy. Return a decision to override the default mechanics
|
|
31
|
+
* for this request, or `undefined` to defer to them. `admit` may draw from reserved headroom above
|
|
32
|
+
* `maxInFlight`; `shed` forces rejection.
|
|
33
|
+
*/
|
|
34
|
+
export type AdmissionPolicy = (
|
|
35
|
+
req: Request,
|
|
36
|
+
evidence: AdmissionEvidence,
|
|
37
|
+
) => { decision: "admit" | "shed"; retryAfterSec?: number } | undefined
|
|
38
|
+
|
|
39
|
+
export interface AdmissionOptions {
|
|
40
|
+
/** Max requests running concurrently before the gate queues or sheds. */
|
|
41
|
+
readonly maxInFlight: number
|
|
42
|
+
/** Event-loop lag ceiling (ms); above it, shed even when slots are free (protects p99). Default ∞. */
|
|
43
|
+
readonly maxLagMs?: number
|
|
44
|
+
/** How many requests may briefly wait for a slot before shedding. `0` (default) never queues. */
|
|
45
|
+
readonly maxQueue?: number
|
|
46
|
+
/** How long a queued request waits for a slot before shedding. Default 50ms. */
|
|
47
|
+
readonly queueTimeoutMs?: number
|
|
48
|
+
/** Reserved slots ABOVE `maxInFlight` that only a policy `admit` may draw from. Default 0. */
|
|
49
|
+
readonly reservedForPolicy?: number
|
|
50
|
+
/** Base `Retry-After`, in seconds, for shed responses. Default 1. */
|
|
51
|
+
readonly baseRetryAfterSec?: number
|
|
52
|
+
/**
|
|
53
|
+
* Live event-loop lag source, in ms. Pass {@link createEventLoopLagSampler} to sample the real loop;
|
|
54
|
+
* default `() => 0` disables lag-based shedding (in-flight only).
|
|
55
|
+
*/
|
|
56
|
+
readonly lagMs?: () => number
|
|
57
|
+
readonly policy?: AdmissionPolicy
|
|
58
|
+
/** Test seam for deterministic queue timeouts; defaults to real timers. */
|
|
59
|
+
readonly setTimer?: (fn: () => void, ms: number) => { cancel(): void }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AdmissionSnapshot {
|
|
63
|
+
readonly inFlight: number
|
|
64
|
+
readonly queued: number
|
|
65
|
+
readonly fastPathAdmits: number
|
|
66
|
+
readonly slowPathEntries: number
|
|
67
|
+
readonly everQueued: number
|
|
68
|
+
readonly shed: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AdmissionControllerHandle extends AdmissionController {
|
|
72
|
+
/** Point-in-time counters for observability (otel gauges/counters). */
|
|
73
|
+
snapshot(): AdmissionSnapshot
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Waiter {
|
|
77
|
+
settled: boolean
|
|
78
|
+
transfer(): void
|
|
79
|
+
shed(): void
|
|
80
|
+
cancel(): void
|
|
81
|
+
cleanup(): void
|
|
82
|
+
timer: { cancel(): void }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const OVERLOADED_BODY = JSON.stringify({ ok: false, error: "overloaded" })
|
|
86
|
+
|
|
87
|
+
/** The slice of a `perf_hooks` event-loop-delay histogram the sampler needs. */
|
|
88
|
+
export interface LoopDelayHistogram {
|
|
89
|
+
enable(): void
|
|
90
|
+
reset(): void
|
|
91
|
+
readonly mean: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Acquires a loop-delay histogram for a resolution, or `undefined` when the runtime has none. This is
|
|
96
|
+
* an optional test/runtime seam; the default sampler is a portable timer-drift monitor.
|
|
97
|
+
*/
|
|
98
|
+
export type LoopDelayMonitor = (resolutionMs: number) => LoopDelayHistogram | undefined
|
|
99
|
+
|
|
100
|
+
/** Shared no-op sampler — the fallback when an explicitly supplied monitor has no histogram. */
|
|
101
|
+
const NO_LAG: () => number = () => 0
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Event-loop-lag sampler. By default it measures timer drift using only Web/JS runtime primitives, so
|
|
105
|
+
* it works under Node ESM, Bun, Deno, and workers without a hidden CommonJS `require` fallback. An
|
|
106
|
+
* injected histogram remains available for deterministic tests or a runtime-native monitor. Each read
|
|
107
|
+
* returns recent mean lag and resets the sampling window.
|
|
108
|
+
*/
|
|
109
|
+
export function createEventLoopLagSampler(
|
|
110
|
+
resolutionMs = 20,
|
|
111
|
+
monitor?: LoopDelayMonitor,
|
|
112
|
+
): () => number {
|
|
113
|
+
if (!Number.isFinite(resolutionMs) || resolutionMs <= 0) {
|
|
114
|
+
throw new RangeError("admission: resolutionMs must be a finite positive number")
|
|
115
|
+
}
|
|
116
|
+
if (monitor === undefined) {
|
|
117
|
+
let expected = performance.now() + resolutionMs
|
|
118
|
+
let totalLagMs = 0
|
|
119
|
+
let samples = 0
|
|
120
|
+
const timer = setInterval(() => {
|
|
121
|
+
const now = performance.now()
|
|
122
|
+
totalLagMs += Math.max(0, now - expected)
|
|
123
|
+
samples++
|
|
124
|
+
// Reset from `now` so one long stall is measured once instead of as a train of catch-up ticks.
|
|
125
|
+
expected = now + resolutionMs
|
|
126
|
+
}, resolutionMs)
|
|
127
|
+
// A metrics sampler must never keep a Node/Bun process alive by itself. Browser/Deno numeric timer
|
|
128
|
+
// handles simply do not expose `unref`.
|
|
129
|
+
;(timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.()
|
|
130
|
+
return () => {
|
|
131
|
+
const mean = samples === 0 ? 0 : totalLagMs / samples
|
|
132
|
+
totalLagMs = 0
|
|
133
|
+
samples = 0
|
|
134
|
+
return Number.isFinite(mean) ? mean : 0
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let histogram: LoopDelayHistogram | undefined
|
|
138
|
+
try {
|
|
139
|
+
histogram = monitor(resolutionMs)
|
|
140
|
+
} catch {
|
|
141
|
+
return NO_LAG
|
|
142
|
+
}
|
|
143
|
+
if (histogram === undefined) return NO_LAG
|
|
144
|
+
histogram.enable()
|
|
145
|
+
return () => {
|
|
146
|
+
const meanNs = histogram.mean
|
|
147
|
+
histogram.reset()
|
|
148
|
+
return Number.isFinite(meanNs) ? Math.max(0, meanNs / 1e6 - resolutionMs) : 0
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build a capacity-admission controller. Pass the returned handle as the server's `admission` option.
|
|
154
|
+
*/
|
|
155
|
+
export function createAdmissionController(options: AdmissionOptions): AdmissionControllerHandle {
|
|
156
|
+
const maxInFlight = options.maxInFlight
|
|
157
|
+
if (!Number.isInteger(maxInFlight) || maxInFlight < 1) {
|
|
158
|
+
throw new Error("admission: maxInFlight must be a positive integer")
|
|
159
|
+
}
|
|
160
|
+
const maxLagMs = options.maxLagMs ?? Number.POSITIVE_INFINITY
|
|
161
|
+
if (Number.isNaN(maxLagMs) || maxLagMs < 0) {
|
|
162
|
+
throw new Error("admission: maxLagMs must be a non-negative number")
|
|
163
|
+
}
|
|
164
|
+
const maxQueue = options.maxQueue ?? 0
|
|
165
|
+
if (!Number.isInteger(maxQueue) || maxQueue < 0) {
|
|
166
|
+
throw new Error("admission: maxQueue must be a non-negative integer")
|
|
167
|
+
}
|
|
168
|
+
const queueTimeoutMs = options.queueTimeoutMs ?? 50
|
|
169
|
+
const reserved = options.reservedForPolicy ?? 0
|
|
170
|
+
const baseRetryAfterSec = options.baseRetryAfterSec ?? 1
|
|
171
|
+
if (!Number.isFinite(queueTimeoutMs) || queueTimeoutMs < 0) {
|
|
172
|
+
throw new Error("admission: queueTimeoutMs must be a finite non-negative number")
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isInteger(reserved) || reserved < 0) {
|
|
175
|
+
throw new Error("admission: reservedForPolicy must be a non-negative integer")
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isFinite(baseRetryAfterSec) || baseRetryAfterSec <= 0) {
|
|
178
|
+
throw new Error("admission: baseRetryAfterSec must be a finite positive number")
|
|
179
|
+
}
|
|
180
|
+
const lagMs = options.lagMs ?? (() => 0)
|
|
181
|
+
const policy = options.policy
|
|
182
|
+
const setTimer =
|
|
183
|
+
options.setTimer ??
|
|
184
|
+
((fn, ms) => {
|
|
185
|
+
const id = setTimeout(fn, ms)
|
|
186
|
+
return { cancel: () => clearTimeout(id) }
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
let inFlight = 0
|
|
190
|
+
const queue: Waiter[] = []
|
|
191
|
+
const stats = { fastPathAdmits: 0, slowPathEntries: 0, everQueued: 0, shed: 0 }
|
|
192
|
+
|
|
193
|
+
const shedResponse = (
|
|
194
|
+
reason: ShedReason,
|
|
195
|
+
retryAfterSec = baseRetryAfterSec,
|
|
196
|
+
): AdmissionDecision => {
|
|
197
|
+
const retry =
|
|
198
|
+
Number.isFinite(retryAfterSec) && retryAfterSec >= 0 ? retryAfterSec : baseRetryAfterSec
|
|
199
|
+
stats.shed++
|
|
200
|
+
return {
|
|
201
|
+
admitted: false,
|
|
202
|
+
response: new Response(OVERLOADED_BODY, {
|
|
203
|
+
status: 429,
|
|
204
|
+
headers: {
|
|
205
|
+
"content-type": "application/json",
|
|
206
|
+
"retry-after": String(Math.max(1, Math.ceil(retry))),
|
|
207
|
+
"x-nifra-shed-reason": reason,
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const cancelledResponse = (): AdmissionDecision => ({
|
|
214
|
+
admitted: false,
|
|
215
|
+
response: new Response(JSON.stringify({ ok: false, error: "request_cancelled" }), {
|
|
216
|
+
status: 499,
|
|
217
|
+
headers: {
|
|
218
|
+
"content-type": "application/json",
|
|
219
|
+
"x-nifra-shed-reason": "cancelled",
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// A release handle over an already-occupied slot. Decrement first, then transfer only when total
|
|
225
|
+
// occupancy is below the public limit. This matters when policy traffic occupies reserved headroom:
|
|
226
|
+
// releasing a reserved slot must NOT hand that capacity to an ordinary queued request.
|
|
227
|
+
const makeHandle = (): AdmissionDecision => {
|
|
228
|
+
let released = false
|
|
229
|
+
return {
|
|
230
|
+
admitted: true,
|
|
231
|
+
release() {
|
|
232
|
+
if (released) return
|
|
233
|
+
released = true
|
|
234
|
+
inFlight--
|
|
235
|
+
while (inFlight < maxInFlight && queue.length > 0) {
|
|
236
|
+
const next = queue.shift() as Waiter
|
|
237
|
+
if (next.settled) continue
|
|
238
|
+
next.settled = true
|
|
239
|
+
next.cleanup()
|
|
240
|
+
inFlight++
|
|
241
|
+
next.transfer()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Take a brand-new slot (fast path, policy admit, reserved headroom). */
|
|
249
|
+
const occupy = (): AdmissionDecision => {
|
|
250
|
+
inFlight++
|
|
251
|
+
return makeHandle()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function admit(req: Request): AdmissionDecision | Promise<AdmissionDecision> {
|
|
255
|
+
const measuredLag = lagMs()
|
|
256
|
+
// Invalid capacity evidence must not silently turn protection off.
|
|
257
|
+
const lag =
|
|
258
|
+
Number.isFinite(measuredLag) && measuredLag >= 0 ? measuredLag : Number.POSITIVE_INFINITY
|
|
259
|
+
const evidence: AdmissionEvidence = {
|
|
260
|
+
inFlight,
|
|
261
|
+
maxInFlight,
|
|
262
|
+
lagMs: lag,
|
|
263
|
+
maxLagMs,
|
|
264
|
+
queued: queue.length,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Policy hook first: may force-admit (reserved headroom) or force-shed.
|
|
268
|
+
if (policy !== undefined) {
|
|
269
|
+
const verdict = policy(req, evidence)
|
|
270
|
+
if (verdict?.decision === "shed")
|
|
271
|
+
return shedResponse("policy", verdict.retryAfterSec ?? baseRetryAfterSec)
|
|
272
|
+
if (verdict?.decision === "admit") {
|
|
273
|
+
return inFlight < maxInFlight + reserved ? occupy() : shedResponse("policy")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Loop-lag shed: the box is behind on the event loop — reject regardless of slot count.
|
|
278
|
+
if (lag > maxLagMs) return shedResponse("loop-lag")
|
|
279
|
+
|
|
280
|
+
// FAST PATH (O(1)): a slot is free and lag is fine. No timer, no queue, no extra await.
|
|
281
|
+
if (inFlight < maxInFlight) {
|
|
282
|
+
stats.fastPathAdmits++
|
|
283
|
+
return occupy()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// SLOW PATH: saturated. Briefly queue, else shed.
|
|
287
|
+
stats.slowPathEntries++
|
|
288
|
+
if (maxQueue <= 0 || queue.length >= maxQueue) return shedResponse("inflight")
|
|
289
|
+
stats.everQueued++
|
|
290
|
+
return new Promise<AdmissionDecision>((resolve) => {
|
|
291
|
+
const remove = (waiter: Waiter): void => {
|
|
292
|
+
const i = queue.indexOf(waiter)
|
|
293
|
+
if (i >= 0) queue.splice(i, 1)
|
|
294
|
+
}
|
|
295
|
+
const waiter: Waiter = {
|
|
296
|
+
settled: false,
|
|
297
|
+
transfer: () => resolve(makeHandle()),
|
|
298
|
+
shed: () => resolve(shedResponse("queue-timeout")),
|
|
299
|
+
cancel: () => resolve(cancelledResponse()),
|
|
300
|
+
cleanup: () => {
|
|
301
|
+
waiter.timer.cancel()
|
|
302
|
+
req.signal.removeEventListener("abort", onAbort)
|
|
303
|
+
},
|
|
304
|
+
timer: setTimer(() => {
|
|
305
|
+
if (waiter.settled) return
|
|
306
|
+
waiter.settled = true
|
|
307
|
+
remove(waiter)
|
|
308
|
+
waiter.cleanup()
|
|
309
|
+
waiter.shed()
|
|
310
|
+
}, queueTimeoutMs),
|
|
311
|
+
}
|
|
312
|
+
const onAbort = (): void => {
|
|
313
|
+
if (waiter.settled) return
|
|
314
|
+
waiter.settled = true
|
|
315
|
+
remove(waiter)
|
|
316
|
+
waiter.cleanup()
|
|
317
|
+
waiter.cancel()
|
|
318
|
+
}
|
|
319
|
+
queue.push(waiter)
|
|
320
|
+
req.signal.addEventListener("abort", onAbort, { once: true })
|
|
321
|
+
if (req.signal.aborted) onAbort()
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
admit,
|
|
327
|
+
snapshot: () => ({ inFlight, queued: queue.length, ...stats }),
|
|
328
|
+
}
|
|
329
|
+
}
|
package/src/basic-auth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definePlugin, type NifraPlugin } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
import {
|
|
3
4
|
decodeBase64,
|
|
4
5
|
jsonError,
|
|
@@ -98,7 +99,14 @@ export function basicAuth<P>(
|
|
|
98
99
|
return optional ? undefined : reject(realm)
|
|
99
100
|
}),
|
|
100
101
|
)
|
|
101
|
-
|
|
102
|
+
const instrumented = optional
|
|
103
|
+
? plugin
|
|
104
|
+
: withRouteAssurance(plugin, {
|
|
105
|
+
id: NIFRA_ASSURANCE.AUTHENTICATED,
|
|
106
|
+
source: "basicAuth",
|
|
107
|
+
scope: "subsequent",
|
|
108
|
+
})
|
|
109
|
+
return Object.assign(instrumented, {
|
|
102
110
|
principal: (request: Request): P | string | null => store.get(request) ?? null,
|
|
103
111
|
requirePrincipal: (request: Request): P | string => {
|
|
104
112
|
const principal = store.get(request)
|
package/src/body-limit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { METHODS, type Middleware } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
import { jsonError, SAFE_METHODS } from "./_utils.ts"
|
|
3
4
|
|
|
4
5
|
export interface BodyLimitOptions {
|
|
@@ -35,7 +36,7 @@ export function bodyLimit(options: BodyLimitOptions): Middleware {
|
|
|
35
36
|
const error = options.error ?? "payload_too_large"
|
|
36
37
|
const allowLengthless = options.allowLengthless === true
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
const middleware: Middleware = {
|
|
39
40
|
name: "body-limit",
|
|
40
41
|
onRequest(req) {
|
|
41
42
|
if (methods !== undefined ? !methods.has(req.method) : SAFE_METHODS.has(req.method)) {
|
|
@@ -50,4 +51,16 @@ export function bodyLimit(options: BodyLimitOptions): Middleware {
|
|
|
50
51
|
return undefined
|
|
51
52
|
},
|
|
52
53
|
}
|
|
54
|
+
// Lengthless bodies are deliberately allowed in this mode, so claiming BODY_BOUNDED for the
|
|
55
|
+
// whole route would be false. The middleware still enforces declared Content-Length at runtime.
|
|
56
|
+
if (allowLengthless) return middleware
|
|
57
|
+
return withRouteAssurance(middleware, {
|
|
58
|
+
id: NIFRA_ASSURANCE.BODY_BOUNDED,
|
|
59
|
+
source: "body-limit",
|
|
60
|
+
scope: "global",
|
|
61
|
+
methods:
|
|
62
|
+
options.methods === undefined
|
|
63
|
+
? ["POST", "PUT", "PATCH", "DELETE"]
|
|
64
|
+
: METHODS.filter((method) => methods?.has(method)),
|
|
65
|
+
})
|
|
53
66
|
}
|
package/src/csrf.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { METHODS, type Middleware } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
import {
|
|
3
4
|
base64UrlEncode,
|
|
4
5
|
hmacSha256,
|
|
@@ -84,7 +85,7 @@ export function csrf(options: CsrfOptions): Middleware {
|
|
|
84
85
|
const origins = options.origins !== undefined ? new Set(options.origins) : undefined
|
|
85
86
|
const checkOrigin = options.checkOrigin !== false
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
const middleware: Middleware = {
|
|
88
89
|
name: "csrf",
|
|
89
90
|
async onRequest(req) {
|
|
90
91
|
if (!protectedMethod(req.method, methods)) return undefined
|
|
@@ -99,4 +100,15 @@ export function csrf(options: CsrfOptions): Middleware {
|
|
|
99
100
|
return (await verifyCsrfToken(cookieToken, key)) ? undefined : jsonError(403, "csrf_failed")
|
|
100
101
|
},
|
|
101
102
|
}
|
|
103
|
+
return withRouteAssurance(middleware, {
|
|
104
|
+
id: NIFRA_ASSURANCE.CSRF,
|
|
105
|
+
source: "csrf",
|
|
106
|
+
scope: "global",
|
|
107
|
+
// Middleware may intentionally mention methods the Nifra router does not expose (for example
|
|
108
|
+
// TRACE). Keep runtime behavior unchanged, but publish evidence only for registerable routes.
|
|
109
|
+
methods:
|
|
110
|
+
options.methods === undefined
|
|
111
|
+
? ["POST", "PUT", "PATCH", "DELETE"]
|
|
112
|
+
: METHODS.filter((method) => methods?.has(method)),
|
|
113
|
+
})
|
|
102
114
|
}
|
package/src/idempotency.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { METHODS, type Middleware } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Idempotency keys for unsafe requests — a client retrying a `POST` (dropped connection, impatient
|
|
@@ -280,7 +281,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
280
281
|
const keyOf = options.key ?? defaultIdempotencyKey
|
|
281
282
|
const claimed = new WeakMap<Request, string>()
|
|
282
283
|
|
|
283
|
-
|
|
284
|
+
const middleware: Middleware = {
|
|
284
285
|
name: "idempotency",
|
|
285
286
|
async onRequest(req) {
|
|
286
287
|
if (!methods.has(req.method.toUpperCase())) return undefined
|
|
@@ -326,4 +327,10 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
326
327
|
return captured.response
|
|
327
328
|
},
|
|
328
329
|
}
|
|
330
|
+
return withRouteAssurance(middleware, {
|
|
331
|
+
id: NIFRA_ASSURANCE.IDEMPOTENCY_KEY,
|
|
332
|
+
source: "idempotency",
|
|
333
|
+
scope: "global",
|
|
334
|
+
methods: METHODS.filter((method) => methods.has(method)),
|
|
335
|
+
})
|
|
329
336
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
* `c.requestId` into the handler context.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
export {
|
|
10
|
+
type AdmissionControllerHandle,
|
|
11
|
+
type AdmissionEvidence,
|
|
12
|
+
type AdmissionOptions,
|
|
13
|
+
type AdmissionPolicy,
|
|
14
|
+
type AdmissionSnapshot,
|
|
15
|
+
createAdmissionController,
|
|
16
|
+
createEventLoopLagSampler,
|
|
17
|
+
type LoopDelayHistogram,
|
|
18
|
+
type LoopDelayMonitor,
|
|
19
|
+
type ShedReason,
|
|
20
|
+
} from "./admission.ts"
|
|
9
21
|
export {
|
|
10
22
|
type BasicAuthPlugin,
|
|
11
23
|
type BasicAuthStaticOptions,
|
package/src/ip-restriction.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Middleware } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
import { jsonError, type MaybePromise } from "./_utils.ts"
|
|
3
4
|
|
|
4
5
|
export type IpMatcher = string | ((ip: string, request: Request) => MaybePromise<boolean>)
|
|
@@ -170,18 +171,28 @@ export function ipRestriction(options: IpRestrictionOptions): Middleware {
|
|
|
170
171
|
}
|
|
171
172
|
const error = options.error ?? "ip_forbidden"
|
|
172
173
|
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
174
|
+
return withRouteAssurance<Middleware>(
|
|
175
|
+
{
|
|
176
|
+
name: "ip-restriction",
|
|
177
|
+
async onRequest(req) {
|
|
178
|
+
const ipText = await resolveClientIp(req, options)
|
|
179
|
+
if (ipText === null) return jsonError(403, error)
|
|
180
|
+
const ip = parseIp(ipText)
|
|
181
|
+
if (ip === null) return jsonError(403, error)
|
|
182
|
+
if (await matches(ipText, ip, deny, req)) return jsonError(403, error)
|
|
183
|
+
if (
|
|
184
|
+
allow.ranges.length + allow.fns.length > 0 &&
|
|
185
|
+
!(await matches(ipText, ip, allow, req))
|
|
186
|
+
) {
|
|
187
|
+
return jsonError(403, error)
|
|
188
|
+
}
|
|
189
|
+
return undefined
|
|
190
|
+
},
|
|
185
191
|
},
|
|
186
|
-
|
|
192
|
+
{
|
|
193
|
+
id: NIFRA_ASSURANCE.IP_RESTRICTED,
|
|
194
|
+
source: "ip-restriction",
|
|
195
|
+
scope: "global",
|
|
196
|
+
},
|
|
197
|
+
)
|
|
187
198
|
}
|
package/src/jwt.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definePlugin, type NifraPlugin } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
import {
|
|
3
4
|
base64UrlDecode,
|
|
4
5
|
jsonError,
|
|
@@ -318,7 +319,14 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
|
|
|
318
319
|
}
|
|
319
320
|
}),
|
|
320
321
|
)
|
|
321
|
-
|
|
322
|
+
const instrumented = optional
|
|
323
|
+
? plugin
|
|
324
|
+
: withRouteAssurance(plugin, {
|
|
325
|
+
id: NIFRA_ASSURANCE.AUTHENTICATED,
|
|
326
|
+
source: "jwt",
|
|
327
|
+
scope: "subsequent",
|
|
328
|
+
})
|
|
329
|
+
return Object.assign(instrumented, {
|
|
322
330
|
claims: (request: Request): C | null => store.get(request) ?? null,
|
|
323
331
|
requireClaims: (request: Request): C => {
|
|
324
332
|
const claims = store.get(request)
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Middleware } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
|
|
3
4
|
export interface RateLimitResult {
|
|
4
5
|
/** Hits recorded in the current window, including this one. */
|
|
@@ -216,7 +217,7 @@ export function rateLimit(options: RateLimitOptions): Middleware {
|
|
|
216
217
|
options.key ?? ((req: Request) => defaultKey(req, trustedProxies, header, allowGlobalKey))
|
|
217
218
|
const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
|
|
218
219
|
|
|
219
|
-
|
|
220
|
+
const middleware: Middleware = {
|
|
220
221
|
name: "rate-limit",
|
|
221
222
|
async onRequest(req) {
|
|
222
223
|
const key = keyOf(req)
|
|
@@ -245,4 +246,9 @@ export function rateLimit(options: RateLimitOptions): Middleware {
|
|
|
245
246
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
246
247
|
},
|
|
247
248
|
}
|
|
249
|
+
return withRouteAssurance(middleware, {
|
|
250
|
+
id: NIFRA_ASSURANCE.RATE_LIMITED,
|
|
251
|
+
source: "rate-limit",
|
|
252
|
+
scope: "global",
|
|
253
|
+
})
|
|
248
254
|
}
|
package/src/security-headers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Middleware } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
import { withHeaders } from "./_utils.ts"
|
|
3
4
|
|
|
4
5
|
export interface SecurityHeadersOptions {
|
|
@@ -35,17 +36,24 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
|
|
|
35
36
|
hstsValue = parts.join("; ")
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
return withRouteAssurance<Middleware>(
|
|
40
|
+
{
|
|
41
|
+
name: "security-headers",
|
|
42
|
+
// Mutate in place on the common (mutable-headers) response; clone only for an immutable one
|
|
43
|
+
// (see withHeaders — the old always-clone was ~3% of a realistic request).
|
|
44
|
+
onResponse: (res) =>
|
|
45
|
+
withHeaders(res, (headers) => {
|
|
46
|
+
headers.set("X-Content-Type-Options", "nosniff")
|
|
47
|
+
headers.set("X-Frame-Options", frameOptions)
|
|
48
|
+
headers.set("Referrer-Policy", referrerPolicy)
|
|
49
|
+
if (hstsValue !== undefined) headers.set("Strict-Transport-Security", hstsValue)
|
|
50
|
+
if (csp !== undefined) headers.set("Content-Security-Policy", csp)
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: NIFRA_ASSURANCE.SECURITY_HEADERS,
|
|
55
|
+
source: "security-headers",
|
|
56
|
+
scope: "global",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
51
59
|
}
|
package/src/token-auth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definePlugin, type NifraPlugin } from "@nifrajs/core"
|
|
2
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
3
|
|
|
3
4
|
type MaybePromise<T> = T | Promise<T>
|
|
4
5
|
|
|
@@ -59,7 +60,14 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
|
|
|
59
60
|
return config.optional ? undefined : reject() // 401 short-circuits (skips the handler)
|
|
60
61
|
}),
|
|
61
62
|
)
|
|
62
|
-
|
|
63
|
+
const instrumented = config.optional
|
|
64
|
+
? plugin
|
|
65
|
+
: withRouteAssurance(plugin, {
|
|
66
|
+
id: NIFRA_ASSURANCE.AUTHENTICATED,
|
|
67
|
+
source: config.name,
|
|
68
|
+
scope: "subsequent",
|
|
69
|
+
})
|
|
70
|
+
return Object.assign(instrumented, {
|
|
63
71
|
principal: (request: Request): P | null => store.get(request) ?? null,
|
|
64
72
|
requirePrincipal: (request: Request): P => {
|
|
65
73
|
const principal = store.get(request)
|