@nifrajs/middleware 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/_utils.d.ts +27 -0
- package/dist/_utils.d.ts.map +1 -0
- package/dist/_utils.js +141 -0
- package/dist/_utils.js.map +1 -0
- package/dist/basic-auth.d.ts +27 -0
- package/dist/basic-auth.d.ts.map +1 -0
- package/dist/basic-auth.js +64 -0
- package/dist/basic-auth.js.map +1 -0
- package/dist/body-limit.d.ts +19 -0
- package/dist/body-limit.d.ts.map +1 -0
- package/dist/body-limit.js +42 -0
- package/dist/body-limit.js.map +1 -0
- package/dist/cache-control.d.ts +23 -0
- package/dist/cache-control.d.ts.map +1 -0
- package/dist/cache-control.js +35 -0
- package/dist/cache-control.js.map +1 -0
- package/dist/cache.d.ts +60 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +224 -0
- package/dist/cache.js.map +1 -0
- package/dist/combine.d.ts +7 -0
- package/dist/combine.d.ts.map +1 -0
- package/dist/combine.js +21 -0
- package/dist/combine.js.map +1 -0
- package/dist/compression.d.ts +29 -0
- package/dist/compression.d.ts.map +1 -0
- package/dist/compression.js +105 -0
- package/dist/compression.js.map +1 -0
- package/dist/context-storage.d.ts +14 -0
- package/dist/context-storage.d.ts.map +1 -0
- package/dist/context-storage.js +30 -0
- package/dist/context-storage.js.map +1 -0
- package/dist/cors.d.ts +25 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +75 -0
- package/dist/cors.js.map +1 -0
- package/dist/csrf.d.ts +23 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/csrf.js +68 -0
- package/dist/csrf.js.map +1 -0
- package/dist/etag.d.ts +14 -0
- package/dist/etag.d.ts.map +1 -0
- package/dist/etag.js +95 -0
- package/dist/etag.js.map +1 -0
- package/dist/healthcheck.d.ts +25 -0
- package/dist/healthcheck.d.ts.map +1 -0
- package/dist/healthcheck.js +41 -0
- package/dist/healthcheck.js.map +1 -0
- package/dist/idempotency.d.ts +83 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +217 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/ip-restriction.d.ts +21 -0
- package/dist/ip-restriction.d.ts.map +1 -0
- package/dist/ip-restriction.js +161 -0
- package/dist/ip-restriction.js.map +1 -0
- package/dist/jwt.d.ts +82 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +327 -0
- package/dist/jwt.js.map +1 -0
- package/dist/language.d.ts +20 -0
- package/dist/language.d.ts.map +1 -0
- package/dist/language.js +94 -0
- package/dist/language.js.map +1 -0
- package/dist/logger.d.ts +19 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +28 -0
- package/dist/logger.js.map +1 -0
- package/dist/method-override.d.ts +28 -0
- package/dist/method-override.d.ts.map +1 -0
- package/dist/method-override.js +67 -0
- package/dist/method-override.js.map +1 -0
- package/dist/openapi.d.ts +83 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +100 -0
- package/dist/openapi.js.map +1 -0
- package/dist/powered-by.d.ts +15 -0
- package/dist/powered-by.d.ts.map +1 -0
- package/dist/powered-by.js +23 -0
- package/dist/powered-by.js.map +1 -0
- package/dist/pretty-json.d.ts +18 -0
- package/dist/pretty-json.d.ts.map +1 -0
- package/dist/pretty-json.js +88 -0
- package/dist/pretty-json.js.map +1 -0
- package/dist/rate-limit.d.ts +92 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +172 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/request-id.d.ts +18 -0
- package/dist/request-id.d.ts.map +1 -0
- package/dist/request-id.js +21 -0
- package/dist/request-id.js.map +1 -0
- package/dist/security-headers.d.ts +23 -0
- package/dist/security-headers.d.ts.map +1 -0
- package/dist/security-headers.js +36 -0
- package/dist/security-headers.js.map +1 -0
- package/dist/timing.d.ts +25 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +101 -0
- package/dist/timing.js.map +1 -0
- package/dist/token-auth.d.ts +65 -0
- package/dist/token-auth.d.ts.map +1 -0
- package/dist/token-auth.js +90 -0
- package/dist/token-auth.js.map +1 -0
- package/dist/trailing-slash.d.ts +23 -0
- package/dist/trailing-slash.d.ts.map +1 -0
- package/dist/trailing-slash.js +70 -0
- package/dist/trailing-slash.js.map +1 -0
- package/package.json +68 -0
- package/src/_utils.ts +163 -0
- package/src/basic-auth.ts +109 -0
- package/src/body-limit.ts +53 -0
- package/src/cache-control.ts +45 -0
- package/src/cache.ts +277 -0
- package/src/combine.ts +24 -0
- package/src/compression.ts +111 -0
- package/src/context-storage.ts +36 -0
- package/src/cors.ts +90 -0
- package/src/csrf.ts +102 -0
- package/src/etag.ts +99 -0
- package/src/healthcheck.ts +59 -0
- package/src/idempotency.ts +313 -0
- package/src/index.ts +110 -0
- package/src/ip-restriction.ts +187 -0
- package/src/jwt.ts +441 -0
- package/src/language.ts +109 -0
- package/src/logger.ts +44 -0
- package/src/method-override.ts +87 -0
- package/src/openapi.ts +174 -0
- package/src/powered-by.ts +31 -0
- package/src/pretty-json.ts +97 -0
- package/src/rate-limit.ts +248 -0
- package/src/request-id.ts +30 -0
- package/src/security-headers.ts +51 -0
- package/src/timing.ts +133 -0
- package/src/token-auth.ts +172 -0
- package/src/trailing-slash.ts +94 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Middleware } from "@nifrajs/core"
|
|
2
|
+
|
|
3
|
+
export interface RateLimitResult {
|
|
4
|
+
/** Hits recorded in the current window, including this one. */
|
|
5
|
+
readonly count: number
|
|
6
|
+
/** Epoch-ms when the current window resets. */
|
|
7
|
+
readonly resetAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Counter backend. Production deploys MUST use a shared store (Redis, etc.) so the
|
|
12
|
+
* limit holds across instances — that's a user dependency, not ours, hence the
|
|
13
|
+
* interface. {@link MemoryStore} is for dev / single-instance only.
|
|
14
|
+
*/
|
|
15
|
+
export interface RateLimitStore {
|
|
16
|
+
hit(key: string, windowMs: number): Promise<RateLimitResult>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MemoryStoreOptions {
|
|
20
|
+
/** Allow the in-memory store in production. Off by default — a per-instance limiter is unsafe across instances. */
|
|
21
|
+
readonly allowInProduction?: boolean
|
|
22
|
+
/** Hard cap on tracked client keys; expired keys are evicted first, then oldest active keys. Default `100_000`.
|
|
23
|
+
* Bounds memory against an unbounded key space (bot scans, per-IP buckets). */
|
|
24
|
+
readonly maxKeys?: number
|
|
25
|
+
/** Minimum interval (ms) between amortized sweeps of expired windows. Default `30_000`. */
|
|
26
|
+
readonly sweepIntervalMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Max entries scanned (oldest-first) per eviction looking for an expired victim before falling back
|
|
30
|
+
* to evicting the oldest-inserted. Bounds eviction to O(1) per insertion instead of a full O(n) sweep
|
|
31
|
+
* under a distinct-key flood — the abuse the key cap defends against. */
|
|
32
|
+
const MAX_EVICTION_SCAN = 64
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* In-process fixed-window store. Refuses to run in production unless explicitly allowed.
|
|
36
|
+
*
|
|
37
|
+
* Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
|
|
38
|
+
* `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
|
|
39
|
+
* then oldest active keys).
|
|
40
|
+
* Without this, expired entries for keys never seen again — and the unbounded key space of a bot scan
|
|
41
|
+
* — would accumulate in the map forever and OOM a single-instance deploy.
|
|
42
|
+
*/
|
|
43
|
+
export class MemoryStore implements RateLimitStore {
|
|
44
|
+
private readonly windows = new Map<string, { count: number; resetAt: number }>()
|
|
45
|
+
private readonly maxKeys: number
|
|
46
|
+
private readonly sweepIntervalMs: number
|
|
47
|
+
private lastSweep = 0
|
|
48
|
+
private evictionScans = 0
|
|
49
|
+
|
|
50
|
+
/** Cumulative entries inspected by the eviction scan since construction — an eviction-pressure
|
|
51
|
+
* gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
|
|
52
|
+
* regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
|
|
53
|
+
get evictionScanCount(): number {
|
|
54
|
+
return this.evictionScans
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
constructor(options: MemoryStoreOptions = {}) {
|
|
58
|
+
if (options.allowInProduction !== true && process.env.NODE_ENV === "production") {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"MemoryStore is per-instance and unsafe in production (each instance limits separately). " +
|
|
61
|
+
"Use a shared store (e.g. Redis), or pass { allowInProduction: true } for a single-instance deploy.",
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
this.maxKeys = options.maxKeys ?? 100_000
|
|
65
|
+
if (!Number.isInteger(this.maxKeys) || this.maxKeys < 1) {
|
|
66
|
+
throw new Error("MemoryStore: maxKeys must be a positive integer")
|
|
67
|
+
}
|
|
68
|
+
this.sweepIntervalMs = options.sweepIntervalMs ?? 30_000
|
|
69
|
+
if (!Number.isFinite(this.sweepIntervalMs) || this.sweepIntervalMs < 0) {
|
|
70
|
+
throw new Error("MemoryStore: sweepIntervalMs must be a non-negative number")
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
hit(key: string, windowMs: number): Promise<RateLimitResult> {
|
|
75
|
+
const now = Date.now()
|
|
76
|
+
// Amortized GC: at most once per sweepIntervalMs, drop every window whose reset has passed. This
|
|
77
|
+
// bounds memory to ~the active key set instead of leaking an entry per distinct key ever seen.
|
|
78
|
+
if (now - this.lastSweep >= this.sweepIntervalMs) {
|
|
79
|
+
this.sweepExpired(now)
|
|
80
|
+
this.lastSweep = now
|
|
81
|
+
}
|
|
82
|
+
const current = this.windows.get(key)
|
|
83
|
+
if (current === undefined || now >= current.resetAt) {
|
|
84
|
+
const fresh = { count: 1, resetAt: now + windowMs }
|
|
85
|
+
this.windows.set(key, fresh)
|
|
86
|
+
this.enforceMaxKeys(now)
|
|
87
|
+
return Promise.resolve({ count: fresh.count, resetAt: fresh.resetAt })
|
|
88
|
+
}
|
|
89
|
+
current.count += 1
|
|
90
|
+
return Promise.resolve({ count: current.count, resetAt: current.resetAt })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private sweepExpired(now: number): void {
|
|
94
|
+
for (const [k, v] of this.windows) if (now >= v.resetAt) this.windows.delete(k)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private enforceMaxKeys(now: number): void {
|
|
98
|
+
if (this.windows.size <= this.maxKeys) return
|
|
99
|
+
// Prefer evicting an expired window over an active user's, but bound the work. A full sweep on
|
|
100
|
+
// every insertion is O(n)/request under a distinct-key flood — the exact abuse the cap defends
|
|
101
|
+
// against. Scan only a small fixed budget (oldest-first, where expired entries cluster) for a
|
|
102
|
+
// victim; if none is found, evict the oldest-inserted (O(1)). Expired entries beyond the budget
|
|
103
|
+
// are still reclaimed by the amortized sweep in `hit`.
|
|
104
|
+
while (this.windows.size > this.maxKeys) {
|
|
105
|
+
let victim: string | undefined
|
|
106
|
+
let scanned = 0
|
|
107
|
+
for (const [k, v] of this.windows) {
|
|
108
|
+
this.evictionScans++
|
|
109
|
+
if (now >= v.resetAt) {
|
|
110
|
+
victim = k
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
if (++scanned >= MAX_EVICTION_SCAN) break
|
|
114
|
+
}
|
|
115
|
+
if (victim === undefined) victim = this.windows.keys().next().value
|
|
116
|
+
if (victim === undefined) break
|
|
117
|
+
this.windows.delete(victim)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface RateLimitOptions {
|
|
123
|
+
/** Where counters live. `MemoryStore` for dev; a shared store in production. */
|
|
124
|
+
readonly store: RateLimitStore
|
|
125
|
+
/** Max requests allowed per window. */
|
|
126
|
+
readonly max: number
|
|
127
|
+
/** Window length, in milliseconds. */
|
|
128
|
+
readonly windowMs: number
|
|
129
|
+
/**
|
|
130
|
+
* How many trusted reverse proxies sit in front of the app and append to `X-Forwarded-For`.
|
|
131
|
+
* Default `0`.
|
|
132
|
+
*
|
|
133
|
+
* The default key reads the client IP from `X-Forwarded-For` as the address your **edge** proxy
|
|
134
|
+
* observed — the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
|
|
135
|
+
* second-from-right; …). Your proxies append on the right, so a client can only inject fake hops on
|
|
136
|
+
* the *left*, which this skips → not spoofable when `trustedProxies` matches your topology.
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ With the default `0`, `X-Forwarded-For` is treated as fully client-controlled and **ignored**.
|
|
139
|
+
* Reading the
|
|
140
|
+
* *first* XFF hop — the old behavior — let any client mint a fresh bucket per request and defeat the
|
|
141
|
+
* limiter. Set `trustedProxies` (only safe behind a proxy you control that appends XFF), configure a
|
|
142
|
+
* trusted single-IP {@link header}, or supply a custom {@link key} (e.g. an authenticated user id).
|
|
143
|
+
*/
|
|
144
|
+
readonly trustedProxies?: number
|
|
145
|
+
/** Exact trusted single-IP header, e.g. an infra-set `x-real-ip`. Not read unless configured. */
|
|
146
|
+
readonly header?: string
|
|
147
|
+
/**
|
|
148
|
+
* Allow one shared bucket when no per-request key can be derived. Off by default because it lets one
|
|
149
|
+
* client consume the quota for everyone. Enable only for intentional global throttles.
|
|
150
|
+
*/
|
|
151
|
+
readonly allowGlobalKey?: boolean
|
|
152
|
+
/**
|
|
153
|
+
* Bucket key for a request. Overrides the default XFF-based key entirely — set this for accurate
|
|
154
|
+
* per-client limiting (e.g. an authenticated user id, or a header your proxy sets). A `Middleware`
|
|
155
|
+
* can't see the socket IP (that needs the server instance).
|
|
156
|
+
*/
|
|
157
|
+
readonly key?: (req: Request) => string
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function defaultKey(
|
|
161
|
+
req: Request,
|
|
162
|
+
trustedProxies: number,
|
|
163
|
+
header: string | undefined,
|
|
164
|
+
allowGlobalKey: boolean,
|
|
165
|
+
): string | null {
|
|
166
|
+
if (header !== undefined) {
|
|
167
|
+
const ip = req.headers.get(header)
|
|
168
|
+
if (ip !== null && ip.trim() !== "") return ip.trim()
|
|
169
|
+
}
|
|
170
|
+
if (trustedProxies > 0) {
|
|
171
|
+
const xff = req.headers.get("x-forwarded-for")
|
|
172
|
+
if (xff !== null) {
|
|
173
|
+
const parts = xff.split(",")
|
|
174
|
+
// The leftmost of the trusted suffix (your proxies append on the right) = the address your edge
|
|
175
|
+
// proxy observed = the real client. A client can only prepend fakes further left, which this
|
|
176
|
+
// index skips. A chain shorter than `trustedProxies` (misconfig) → undefined → fall through.
|
|
177
|
+
const ip = parts[parts.length - trustedProxies]?.trim()
|
|
178
|
+
if (ip !== undefined && ip !== "") return ip
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// No trusted proxy (or XFF absent/too-short): XFF isn't trustworthy, so don't derive a per-client key
|
|
182
|
+
// from it. A shared bucket is only safe when the app deliberately asked for a global throttle.
|
|
183
|
+
return allowGlobalKey ? "global" : null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
|
|
188
|
+
* also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
|
|
189
|
+
* `RateLimit-Limit/Remaining/Reset` (added in `onResponse`, keyed off the request).
|
|
190
|
+
*/
|
|
191
|
+
export function rateLimit(options: RateLimitOptions): Middleware {
|
|
192
|
+
const { store, max, windowMs } = options
|
|
193
|
+
if (!Number.isInteger(max) || max < 1)
|
|
194
|
+
throw new Error("rateLimit: max must be a positive integer")
|
|
195
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
196
|
+
throw new Error("rateLimit: windowMs must be a positive number")
|
|
197
|
+
}
|
|
198
|
+
const trustedProxies = options.trustedProxies ?? 0
|
|
199
|
+
if (!Number.isInteger(trustedProxies) || trustedProxies < 0) {
|
|
200
|
+
throw new Error("rateLimit: trustedProxies must be a non-negative integer")
|
|
201
|
+
}
|
|
202
|
+
const header = options.header?.trim().toLowerCase()
|
|
203
|
+
if (header !== undefined && header.trim() === "") throw new Error("rateLimit: header is empty")
|
|
204
|
+
const allowGlobalKey = options.allowGlobalKey === true
|
|
205
|
+
if (
|
|
206
|
+
options.key === undefined &&
|
|
207
|
+
header === undefined &&
|
|
208
|
+
trustedProxies === 0 &&
|
|
209
|
+
!allowGlobalKey
|
|
210
|
+
) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
"rateLimit: configure key, header, or trustedProxies; pass allowGlobalKey: true only for an intentional shared bucket",
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
const keyOf =
|
|
216
|
+
options.key ?? ((req: Request) => defaultKey(req, trustedProxies, header, allowGlobalKey))
|
|
217
|
+
const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
name: "rate-limit",
|
|
221
|
+
async onRequest(req) {
|
|
222
|
+
const key = keyOf(req)
|
|
223
|
+
if (typeof key !== "string" || key.trim() === "") {
|
|
224
|
+
return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
|
|
225
|
+
}
|
|
226
|
+
const { count, resetAt } = await store.hit(key, windowMs)
|
|
227
|
+
const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
|
|
228
|
+
quota.set(req, { remaining: Math.max(0, max - count), resetSeconds })
|
|
229
|
+
if (count > max) {
|
|
230
|
+
return new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
|
|
231
|
+
status: 429,
|
|
232
|
+
headers: { "content-type": "application/json", "retry-after": String(resetSeconds) },
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
return undefined
|
|
236
|
+
},
|
|
237
|
+
onResponse(res, req) {
|
|
238
|
+
const info = quota.get(req)
|
|
239
|
+
if (info === undefined) return res
|
|
240
|
+
quota.delete(req)
|
|
241
|
+
const headers = new Headers(res.headers)
|
|
242
|
+
headers.set("RateLimit-Limit", String(max))
|
|
243
|
+
headers.set("RateLimit-Remaining", String(info.remaining))
|
|
244
|
+
headers.set("RateLimit-Reset", String(info.resetSeconds))
|
|
245
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { definePlugin } from "@nifrajs/core"
|
|
2
|
+
|
|
3
|
+
export interface RequestIdOptions {
|
|
4
|
+
/** Header read for an inbound id (trace propagation) + echoed on the response. Default `"x-request-id"`. */
|
|
5
|
+
readonly header?: string
|
|
6
|
+
/** Generate an id when the inbound header is absent. Default `crypto.randomUUID()`. */
|
|
7
|
+
readonly generate?: () => string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
|
|
12
|
+
* `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
|
|
13
|
+
* threaded by `derive`), and echoes it on the response header. Idempotent — applying it twice is a
|
|
14
|
+
* no-op.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* app.use(requestId()) // c.requestId available downstream; X-Request-Id on responses
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function requestId(options: RequestIdOptions = {}) {
|
|
21
|
+
const header = options.header ?? "x-request-id"
|
|
22
|
+
const generate = options.generate ?? (() => crypto.randomUUID())
|
|
23
|
+
return definePlugin("requestId", (app) =>
|
|
24
|
+
app.derive((c) => {
|
|
25
|
+
const id = c.req.headers.get(header) ?? generate()
|
|
26
|
+
c.set.headers[header] = id // echo on the response (route handlers)
|
|
27
|
+
return { requestId: id }
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Middleware } from "@nifrajs/core"
|
|
2
|
+
import { withHeaders } from "./_utils.ts"
|
|
3
|
+
|
|
4
|
+
export interface SecurityHeadersOptions {
|
|
5
|
+
/** `Strict-Transport-Security`. Off by default — opt in once you're sure you're HTTPS-only. */
|
|
6
|
+
readonly hsts?: {
|
|
7
|
+
readonly maxAge: number
|
|
8
|
+
readonly includeSubDomains?: boolean
|
|
9
|
+
readonly preload?: boolean
|
|
10
|
+
}
|
|
11
|
+
/** `Content-Security-Policy` value. Off by default (app-specific). */
|
|
12
|
+
readonly contentSecurityPolicy?: string
|
|
13
|
+
/** `X-Frame-Options`. Default `"DENY"`. */
|
|
14
|
+
readonly frameOptions?: "DENY" | "SAMEORIGIN"
|
|
15
|
+
/** `Referrer-Policy`. Default `"no-referrer"`. */
|
|
16
|
+
readonly referrerPolicy?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A safe-by-default set of response security headers (`onResponse`, so they cover
|
|
21
|
+
* errors and 404s too): `X-Content-Type-Options: nosniff`, `X-Frame-Options`, and
|
|
22
|
+
* `Referrer-Policy` always; `Strict-Transport-Security` and `Content-Security-Policy`
|
|
23
|
+
* only when configured (both are environment-/app-specific).
|
|
24
|
+
*/
|
|
25
|
+
export function securityHeaders(options: SecurityHeadersOptions = {}): Middleware {
|
|
26
|
+
const frameOptions = options.frameOptions ?? "DENY"
|
|
27
|
+
const referrerPolicy = options.referrerPolicy ?? "no-referrer"
|
|
28
|
+
const csp = options.contentSecurityPolicy
|
|
29
|
+
|
|
30
|
+
let hstsValue: string | undefined
|
|
31
|
+
if (options.hsts !== undefined) {
|
|
32
|
+
const parts = [`max-age=${options.hsts.maxAge}`]
|
|
33
|
+
if (options.hsts.includeSubDomains) parts.push("includeSubDomains")
|
|
34
|
+
if (options.hsts.preload) parts.push("preload")
|
|
35
|
+
hstsValue = parts.join("; ")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: "security-headers",
|
|
40
|
+
// Mutate in place on the common (mutable-headers) response; clone only for an immutable one
|
|
41
|
+
// (see withHeaders — the old always-clone was ~3% of a realistic request).
|
|
42
|
+
onResponse: (res) =>
|
|
43
|
+
withHeaders(res, (headers) => {
|
|
44
|
+
headers.set("X-Content-Type-Options", "nosniff")
|
|
45
|
+
headers.set("X-Frame-Options", frameOptions)
|
|
46
|
+
headers.set("Referrer-Policy", referrerPolicy)
|
|
47
|
+
if (hstsValue !== undefined) headers.set("Strict-Transport-Security", hstsValue)
|
|
48
|
+
if (csp !== undefined) headers.set("Content-Security-Policy", csp)
|
|
49
|
+
}),
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { definePlugin } from "@nifrajs/core"
|
|
2
|
+
import { withHeaders } from "./_utils.ts"
|
|
3
|
+
|
|
4
|
+
export interface TimingMetric {
|
|
5
|
+
readonly name: string
|
|
6
|
+
readonly duration: number
|
|
7
|
+
readonly description?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TimingControls {
|
|
11
|
+
mark(name: string): void
|
|
12
|
+
measure(name: string, start: string, end?: string): void
|
|
13
|
+
metric(name: string, duration: number, description?: string): void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TimingOptions {
|
|
17
|
+
/** Name for the automatic total request metric. Default `"total"`; set `false` to disable. */
|
|
18
|
+
readonly total?: string | false
|
|
19
|
+
/** Decimal places for durations. Default `1`. */
|
|
20
|
+
readonly precision?: number
|
|
21
|
+
/** Only emit timing for matching requests. Default `true`. */
|
|
22
|
+
readonly enabled?: boolean | ((request: Request) => boolean)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TimingState {
|
|
26
|
+
readonly start: number
|
|
27
|
+
readonly marks: Map<string, number>
|
|
28
|
+
readonly metrics: TimingMetric[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
32
|
+
|
|
33
|
+
function assertMetricName(name: string): void {
|
|
34
|
+
if (!TOKEN.test(name)) throw new Error(`timing: invalid metric name "${name}"`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertDuration(duration: number): void {
|
|
38
|
+
if (!Number.isFinite(duration) || duration < 0) {
|
|
39
|
+
throw new Error("timing: duration must be a finite non-negative number")
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function quoteDescription(value: string): string {
|
|
44
|
+
let out = ""
|
|
45
|
+
for (let i = 0; i < value.length; i++) {
|
|
46
|
+
const code = value.charCodeAt(i)
|
|
47
|
+
if (code < 0x20 || code === 0x7f) continue
|
|
48
|
+
const ch = value[i] ?? ""
|
|
49
|
+
out += ch === "\\" || ch === '"' ? `\\${ch}` : ch
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatMetric(metric: TimingMetric, precision: number): string {
|
|
55
|
+
let out = `${metric.name};dur=${metric.duration.toFixed(precision)}`
|
|
56
|
+
if (metric.description !== undefined) out += `;desc="${quoteDescription(metric.description)}"`
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Adds a `Server-Timing` response header and typed `c.timing` controls for custom metrics.
|
|
62
|
+
* Put request-rewriting middleware (for example `methodOverride`) before `timing()` so timing is
|
|
63
|
+
* attached to the final routed request.
|
|
64
|
+
*/
|
|
65
|
+
export function timing(options: TimingOptions = {}) {
|
|
66
|
+
const totalName = options.total === undefined ? "total" : options.total
|
|
67
|
+
if (totalName !== false) assertMetricName(totalName)
|
|
68
|
+
const precision = options.precision ?? 1
|
|
69
|
+
if (!Number.isInteger(precision) || precision < 0 || precision > 6) {
|
|
70
|
+
throw new Error("timing: precision must be an integer from 0 to 6")
|
|
71
|
+
}
|
|
72
|
+
const enabled = options.enabled ?? true
|
|
73
|
+
const isEnabled = typeof enabled === "function" ? enabled : enabled ? () => true : () => false
|
|
74
|
+
const states = new WeakMap<Request, TimingState>()
|
|
75
|
+
|
|
76
|
+
const controls = (req: Request): TimingControls => ({
|
|
77
|
+
mark(name) {
|
|
78
|
+
assertMetricName(name)
|
|
79
|
+
const state = states.get(req)
|
|
80
|
+
if (state !== undefined) state.marks.set(name, performance.now())
|
|
81
|
+
},
|
|
82
|
+
measure(name, start, end) {
|
|
83
|
+
assertMetricName(name)
|
|
84
|
+
const state = states.get(req)
|
|
85
|
+
if (state === undefined) return
|
|
86
|
+
const startAt = state.marks.get(start)
|
|
87
|
+
const endAt = end === undefined ? performance.now() : state.marks.get(end)
|
|
88
|
+
if (startAt === undefined || endAt === undefined) {
|
|
89
|
+
throw new Error("timing: unknown mark")
|
|
90
|
+
}
|
|
91
|
+
const duration = endAt - startAt
|
|
92
|
+
assertDuration(duration)
|
|
93
|
+
state.metrics.push({ name, duration })
|
|
94
|
+
},
|
|
95
|
+
metric(name, duration, description) {
|
|
96
|
+
assertMetricName(name)
|
|
97
|
+
assertDuration(duration)
|
|
98
|
+
const state = states.get(req)
|
|
99
|
+
if (state !== undefined) {
|
|
100
|
+
state.metrics.push(
|
|
101
|
+
description === undefined ? { name, duration } : { name, duration, description },
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return definePlugin("timing", (app) =>
|
|
108
|
+
app
|
|
109
|
+
.onRequest((req) => {
|
|
110
|
+
if (!isEnabled(req)) return undefined
|
|
111
|
+
states.set(req, { start: performance.now(), marks: new Map(), metrics: [] })
|
|
112
|
+
return undefined
|
|
113
|
+
})
|
|
114
|
+
.derive((c) => ({ timing: controls(c.req) }))
|
|
115
|
+
.onResponse((res, req) => {
|
|
116
|
+
const state = states.get(req)
|
|
117
|
+
if (state === undefined) return res
|
|
118
|
+
states.delete(req)
|
|
119
|
+
|
|
120
|
+
const metrics =
|
|
121
|
+
totalName === false
|
|
122
|
+
? state.metrics
|
|
123
|
+
: [{ name: totalName, duration: performance.now() - state.start }, ...state.metrics]
|
|
124
|
+
if (metrics.length === 0) return res
|
|
125
|
+
|
|
126
|
+
const value = metrics.map((metric) => formatMetric(metric, precision)).join(", ")
|
|
127
|
+
return withHeaders(res, (headers) => {
|
|
128
|
+
const existing = headers.get("server-timing")
|
|
129
|
+
headers.set("server-timing", existing === null ? value : `${existing}, ${value}`)
|
|
130
|
+
})
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { definePlugin, type NifraPlugin } from "@nifrajs/core"
|
|
2
|
+
|
|
3
|
+
type MaybePromise<T> = T | Promise<T>
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` — it rejects unauthorized
|
|
7
|
+
* requests to **routes defined after it** with `401` (unless `optional`). Read the verified principal
|
|
8
|
+
* inside a handler/loader via {@link AuthPlugin.principal} (nullable) or
|
|
9
|
+
* {@link AuthPlugin.requirePrincipal} (throws `401` when absent). The principal is verified once per
|
|
10
|
+
* request and cached, so reading it is free.
|
|
11
|
+
*
|
|
12
|
+
* `beforeHandle` is **order-scoped**: place `app.use(auth)` before the routes it should guard. Hold the
|
|
13
|
+
* returned instance to read the principal (mirrors `@nifrajs/auth`'s `sessions.get(c)` and
|
|
14
|
+
* `@nifrajs/better-auth`'s `getSession(auth, req)`):
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const auth = bearer({ verify: (t) => lookupUser(t) }) // P = User (inferred)
|
|
18
|
+
* const app = server().use(auth).get("/me", (c) => auth.requirePrincipal(c.req))
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export type AuthPlugin<P> = NifraPlugin & {
|
|
22
|
+
/** The verified principal for this request, or `null` (no/invalid token in `optional` mode). */
|
|
23
|
+
principal(request: Request): P | null
|
|
24
|
+
/** The verified principal, or **throws a `401` `Response`** when absent. */
|
|
25
|
+
requirePrincipal(request: Request): P
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TokenAuthConfig<P> {
|
|
29
|
+
readonly name: string
|
|
30
|
+
/** Pull the credential out of the request (e.g. the `Bearer` token or an `x-api-key` header). */
|
|
31
|
+
readonly extract: (request: Request) => string | undefined
|
|
32
|
+
/** Verify the credential → a principal (truthy) or `null`/`undefined` (rejected). */
|
|
33
|
+
readonly verify: (token: string) => MaybePromise<P | null | undefined>
|
|
34
|
+
readonly optional: boolean
|
|
35
|
+
/** `WWW-Authenticate` challenge value sent on `401` (bearer only). */
|
|
36
|
+
readonly challenge?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
|
|
40
|
+
// Per-request principal cache: verify runs once (in beforeHandle), reads are free + can't desync.
|
|
41
|
+
const store = new WeakMap<Request, P>()
|
|
42
|
+
const reject = (): Response =>
|
|
43
|
+
Response.json(
|
|
44
|
+
{ ok: false, error: "unauthorized" },
|
|
45
|
+
{
|
|
46
|
+
status: 401,
|
|
47
|
+
headers: config.challenge !== undefined ? { "www-authenticate": config.challenge } : {},
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
const plugin = definePlugin(config.name, (app) =>
|
|
51
|
+
app.beforeHandle(async (c: { readonly req: Request }) => {
|
|
52
|
+
const token = config.extract(c.req)
|
|
53
|
+
// Empty string is treated as "no credential" — never passed to verify.
|
|
54
|
+
const principal = token !== undefined && token !== "" ? await config.verify(token) : null
|
|
55
|
+
if (principal !== null && principal !== undefined) {
|
|
56
|
+
store.set(c.req, principal)
|
|
57
|
+
return undefined // authorized — continue to the handler
|
|
58
|
+
}
|
|
59
|
+
return config.optional ? undefined : reject() // 401 short-circuits (skips the handler)
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
return Object.assign(plugin, {
|
|
63
|
+
principal: (request: Request): P | null => store.get(request) ?? null,
|
|
64
|
+
requirePrincipal: (request: Request): P => {
|
|
65
|
+
const principal = store.get(request)
|
|
66
|
+
if (principal === undefined) throw reject()
|
|
67
|
+
return principal
|
|
68
|
+
},
|
|
69
|
+
}) as AuthPlugin<P>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface BearerOptions<P> {
|
|
73
|
+
/** Verify a bearer token → a principal (truthy) or `null`/`undefined` (rejected). May be async
|
|
74
|
+
* (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare — never `===`. */
|
|
75
|
+
readonly verify: (token: string) => MaybePromise<P | null | undefined>
|
|
76
|
+
/** When `true`, requests without a valid token pass through (`principal` is `null`) instead of `401`. */
|
|
77
|
+
readonly optional?: boolean
|
|
78
|
+
/** `realm` for the `WWW-Authenticate: Bearer` header on `401`. Default `"api"`. */
|
|
79
|
+
readonly realm?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
|
|
84
|
+
* `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
|
|
85
|
+
* verified principal is read via the returned instance — see {@link AuthPlugin}.
|
|
86
|
+
*/
|
|
87
|
+
export function bearer<P>(options: BearerOptions<P>): AuthPlugin<P> {
|
|
88
|
+
const realm = options.realm ?? "api"
|
|
89
|
+
return createTokenAuth({
|
|
90
|
+
name: "bearer",
|
|
91
|
+
extract: (request) => {
|
|
92
|
+
const header = request.headers.get("authorization")
|
|
93
|
+
return header?.startsWith("Bearer ") === true ? header.slice(7).trim() : undefined
|
|
94
|
+
},
|
|
95
|
+
verify: options.verify,
|
|
96
|
+
optional: options.optional === true,
|
|
97
|
+
challenge: `Bearer realm="${realm}"`,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ApiKeyVerifyOptions<P> {
|
|
102
|
+
/** Verify an API key → a principal or `null`/`undefined` (rejected). May be async. */
|
|
103
|
+
readonly verify: (key: string) => MaybePromise<P | null | undefined>
|
|
104
|
+
/** Header carrying the key. Default `"x-api-key"`. */
|
|
105
|
+
readonly header?: string
|
|
106
|
+
readonly optional?: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ApiKeyStaticOptions {
|
|
110
|
+
/** A fixed set of valid keys. Compared in **constant time** (see below); the matched key becomes the
|
|
111
|
+
* principal. Use `verify` instead for DB-backed / per-tenant keys. */
|
|
112
|
+
readonly keys: readonly string[]
|
|
113
|
+
readonly header?: string
|
|
114
|
+
readonly optional?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const textEncoder = new TextEncoder()
|
|
118
|
+
|
|
119
|
+
async function sha256(input: string): Promise<Uint8Array> {
|
|
120
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", textEncoder.encode(input)))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Constant-time byte compare. Inputs here are always 32-byte SHA-256 digests, so the early
|
|
124
|
+
* length check never leaks key length (digests are fixed-width). */
|
|
125
|
+
function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
126
|
+
if (a.length !== b.length) return false
|
|
127
|
+
let diff = 0
|
|
128
|
+
for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0)
|
|
129
|
+
return diff === 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build a constant-time verifier for a fixed key set. Each valid key is hashed once (SHA-256, fixed
|
|
134
|
+
* 32 bytes); per request the candidate is hashed and compared against every digest **without early
|
|
135
|
+
* exit**, so timing depends only on the (public) key count — never on the secret value or which key
|
|
136
|
+
* matched. Portable across all runtimes via WebCrypto.
|
|
137
|
+
*/
|
|
138
|
+
function staticKeyVerify(keys: readonly string[]): (candidate: string) => Promise<string | null> {
|
|
139
|
+
const validDigests = Promise.all(keys.map((key) => sha256(key)))
|
|
140
|
+
return async (candidate) => {
|
|
141
|
+
const candidateDigest = await sha256(candidate)
|
|
142
|
+
let matched = false
|
|
143
|
+
for (const digest of await validDigests) {
|
|
144
|
+
// No early `break`: every comparison runs so total time is independent of which key (if any) hit.
|
|
145
|
+
if (timingSafeEqualBytes(candidateDigest, digest)) matched = true
|
|
146
|
+
}
|
|
147
|
+
return matched ? candidate : null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* API-key authentication via a header (default `x-api-key`). Two forms:
|
|
153
|
+
* - `apiKey({ keys })` — a fixed key set, compared in **constant time**; the matched key is the principal.
|
|
154
|
+
* - `apiKey({ verify })` — custom (e.g. DB-backed) verification returning a typed principal.
|
|
155
|
+
*
|
|
156
|
+
* Rejects missing/invalid keys with `401` (unless `optional`). Read the principal via the returned
|
|
157
|
+
* instance — see {@link AuthPlugin}.
|
|
158
|
+
*/
|
|
159
|
+
export function apiKey(options: ApiKeyStaticOptions): AuthPlugin<string>
|
|
160
|
+
export function apiKey<P>(options: ApiKeyVerifyOptions<P>): AuthPlugin<P>
|
|
161
|
+
export function apiKey<P>(
|
|
162
|
+
options: ApiKeyStaticOptions | ApiKeyVerifyOptions<P>,
|
|
163
|
+
): AuthPlugin<P | string> {
|
|
164
|
+
const header = (options.header ?? "x-api-key").toLowerCase()
|
|
165
|
+
const verify = "keys" in options ? staticKeyVerify(options.keys) : options.verify
|
|
166
|
+
return createTokenAuth<P | string>({
|
|
167
|
+
name: "apiKey",
|
|
168
|
+
extract: (request) => request.headers.get(header) ?? undefined,
|
|
169
|
+
verify,
|
|
170
|
+
optional: options.optional === true,
|
|
171
|
+
})
|
|
172
|
+
}
|