@nifrajs/middleware 2.8.2 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/_utils.d.ts +6 -1
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +39 -28
- package/dist/_utils.js.map +1 -1
- package/dist/cache-control.d.ts.map +1 -1
- package/dist/cache-control.js +31 -15
- package/dist/cache-control.js.map +1 -1
- package/dist/cache.d.ts +1 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +2 -3
- package/dist/cache.js.map +1 -1
- package/dist/compression.d.ts +10 -10
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +191 -70
- package/dist/compression.js.map +1 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +27 -16
- package/dist/cors.js.map +1 -1
- package/dist/etag.d.ts +4 -2
- package/dist/etag.d.ts.map +1 -1
- package/dist/etag.js +135 -63
- package/dist/etag.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +14 -15
- package/dist/language.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +34 -16
- package/dist/logger.js.map +1 -1
- package/dist/powered-by.d.ts.map +1 -1
- package/dist/powered-by.js +8 -5
- package/dist/powered-by.js.map +1 -1
- package/dist/pretty-json.d.ts +5 -2
- package/dist/pretty-json.d.ts.map +1 -1
- package/dist/pretty-json.js +85 -64
- package/dist/pretty-json.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +76 -19
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.js +1 -1
- package/dist/request-id.js.map +1 -1
- package/dist/security-headers.d.ts +8 -4
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +18 -16
- package/dist/security-headers.js.map +1 -1
- package/package.json +3 -3
- package/src/_utils.ts +39 -24
- package/src/cache-control.ts +27 -14
- package/src/cache.ts +2 -4
- package/src/compression.ts +202 -62
- package/src/cors.ts +23 -14
- package/src/etag.ts +141 -59
- package/src/language.ts +12 -13
- package/src/logger.ts +24 -6
- package/src/powered-by.ts +8 -4
- package/src/pretty-json.ts +95 -70
- package/src/rate-limit.ts +86 -20
- package/src/request-id.ts +1 -1
- package/src/security-headers.ts +17 -15
package/src/powered-by.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Middleware } from "@nifrajs/core/server"
|
|
2
|
-
import { withHeaders } from "./_utils.ts"
|
|
3
2
|
|
|
4
3
|
export interface PoweredByOptions {
|
|
5
4
|
/** Header name. Default `"x-powered-by"`. */
|
|
@@ -21,11 +20,16 @@ export function poweredBy(options: PoweredByOptions = {}): Middleware {
|
|
|
21
20
|
if (header.trim() === "") throw new Error("poweredBy: header is empty")
|
|
22
21
|
if (/[\r\n]/.test(value)) throw new Error("poweredBy: value contains a newline")
|
|
23
22
|
|
|
23
|
+
// Respecting an existing value IS the static tier's contract (declared headers are defaults), so
|
|
24
|
+
// the default configuration declares the header instead of writing it per response and leaves the
|
|
25
|
+
// app on its fused/native lanes. Overwriting is a decision about the response, so it stays a hook.
|
|
26
|
+
if (respectExisting) {
|
|
27
|
+
return { name: "powered-by", responseHeaders: { [header]: value } }
|
|
28
|
+
}
|
|
24
29
|
return {
|
|
25
30
|
name: "powered-by",
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return withHeaders(res, (headers) => headers.set(header, value))
|
|
31
|
+
onResponseHeaders(headers) {
|
|
32
|
+
headers.set(header, value)
|
|
29
33
|
},
|
|
30
34
|
}
|
|
31
35
|
}
|
package/src/pretty-json.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definePlugin } from "@nifrajs/core/server"
|
|
1
|
+
import { definePlugin, type NodeRequestContext } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
export interface PrettyJsonOptions {
|
|
4
4
|
/** JSON indentation spaces. Default `2`. */
|
|
@@ -9,8 +9,10 @@ export interface PrettyJsonOptions {
|
|
|
9
9
|
readonly newline?: boolean
|
|
10
10
|
/** Optional query toggle. When set, pretty printing runs only when the parameter is present. */
|
|
11
11
|
readonly query?: string | false
|
|
12
|
-
/** Only pretty-print matching requests
|
|
13
|
-
|
|
12
|
+
/** Only pretty-print matching requests (the predicate receives the portable request view -
|
|
13
|
+
* `{ method, url, header(name) }` - so the check runs without materializing a `Request`).
|
|
14
|
+
* Default `true`. */
|
|
15
|
+
readonly enabled?: boolean | ((request: NodeRequestContext) => boolean)
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const JSON_TYPE = /^(?:application\/json|[^/]+\/[^;]+\+json)(?:\s*;|$)/i
|
|
@@ -19,25 +21,21 @@ function isJson(contentType: string): boolean {
|
|
|
19
21
|
return JSON_TYPE.test(contentType)
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
function parseLength(value: string | null): number | undefined {
|
|
23
|
-
if (value === null) return undefined
|
|
24
|
-
if (!/^(?:0|[1-9]\d*)$/.test(value)) return undefined
|
|
25
|
-
return Number(value)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The two reader methods this file uses, structurally.
|
|
30
|
-
*
|
|
31
|
-
* Named by use rather than imported: `lib.dom`'s `ReadableStreamDefaultReader` and Bun's augmented one
|
|
32
|
-
* are not mutually assignable (Bun adds `readMany`), and `ReturnType<…getReader>` resolves to the BYOB
|
|
33
|
-
* overload, whose `read` requires an argument. A structural shape sidesteps all of it without a cast.
|
|
34
|
-
*/
|
|
35
24
|
interface ChunkReader {
|
|
36
25
|
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array }>
|
|
37
26
|
cancel(reason?: unknown): Promise<void>
|
|
38
27
|
}
|
|
39
28
|
|
|
40
|
-
|
|
29
|
+
const concat = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
|
|
30
|
+
const out = new Uint8Array(total)
|
|
31
|
+
let offset = 0
|
|
32
|
+
for (const chunk of chunks) {
|
|
33
|
+
out.set(chunk, offset)
|
|
34
|
+
offset += chunk.byteLength
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
41
39
|
function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkReader): Response {
|
|
42
40
|
const stream = new ReadableStream<Uint8Array>({
|
|
43
41
|
start(controller) {
|
|
@@ -46,12 +44,9 @@ function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkR
|
|
|
46
44
|
async pull(controller) {
|
|
47
45
|
try {
|
|
48
46
|
const { done, value } = await reader.read()
|
|
49
|
-
// No value and not done cannot happen for a byte stream, but it means the same thing here:
|
|
50
|
-
// there is nothing further to emit.
|
|
51
47
|
if (done || value === undefined) controller.close()
|
|
52
48
|
else controller.enqueue(value)
|
|
53
49
|
} catch (error) {
|
|
54
|
-
// The upstream body failed; surface that to the client rather than truncating silently.
|
|
55
50
|
controller.error(error)
|
|
56
51
|
}
|
|
57
52
|
},
|
|
@@ -64,37 +59,26 @@ function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkR
|
|
|
64
59
|
})
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
type Peeked =
|
|
62
|
+
type Peeked =
|
|
63
|
+
| { readonly text: string; readonly bytes: Uint8Array }
|
|
64
|
+
| { readonly response: Response }
|
|
68
65
|
|
|
69
|
-
/**
|
|
70
|
-
* Read up to `maxBytes` of a response, without disturbing it when the answer is "pass through".
|
|
71
|
-
*
|
|
72
|
-
* `Response.clone()` is the obvious way to peek and is a trap here. Cancelling the clone's reader once
|
|
73
|
-
* the cap is exceeded also stalls the ORIGINAL body in Bun, so an oversized streamed response hung the
|
|
74
|
-
* client instead of passing through - a hang, from a middleware whose entire job is cosmetic. (A clone
|
|
75
|
-
* read to completion is fine; only the cancel breaks it, which is why every buffered case passed and
|
|
76
|
-
* only a streamed one failed.) Abandoning the clone rather than cancelling it trades the hang for
|
|
77
|
-
* buffering the whole oversized body, which is the exact cost the cap exists to avoid.
|
|
78
|
-
*
|
|
79
|
-
* So the body is read directly and, when it proves too large, replayed: the bytes already pulled are
|
|
80
|
-
* re-emitted ahead of the rest of the same reader. No tee, nothing buffered past the cap, and the
|
|
81
|
-
* response the client receives is byte-for-byte the one the handler produced.
|
|
82
|
-
*/
|
|
83
66
|
async function peekText(res: Response, maxBytes: number): Promise<Peeked> {
|
|
84
67
|
const body = res.body
|
|
85
68
|
if (body === null) return { response: res }
|
|
86
|
-
const reader = body.getReader()
|
|
69
|
+
const reader = body.getReader() as unknown as ChunkReader
|
|
87
70
|
const chunks: Uint8Array[] = []
|
|
88
71
|
let total = 0
|
|
89
72
|
try {
|
|
90
73
|
while (true) {
|
|
91
74
|
const { done, value } = await reader.read()
|
|
92
75
|
if (done) {
|
|
93
|
-
const decoder = new TextDecoder()
|
|
76
|
+
const decoder = new TextDecoder("utf-8", { fatal: true })
|
|
94
77
|
let text = ""
|
|
95
78
|
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true })
|
|
96
|
-
return { text: text + decoder.decode() }
|
|
79
|
+
return { text: text + decoder.decode(), bytes: concat(chunks, total) }
|
|
97
80
|
}
|
|
81
|
+
if (value === undefined) return { response: replayFrom(res, chunks, reader) }
|
|
98
82
|
chunks.push(value)
|
|
99
83
|
total += value.byteLength
|
|
100
84
|
if (total > maxBytes) return { response: replayFrom(res, chunks, reader) }
|
|
@@ -104,6 +88,10 @@ async function peekText(res: Response, maxBytes: number): Promise<Peeked> {
|
|
|
104
88
|
}
|
|
105
89
|
}
|
|
106
90
|
|
|
91
|
+
function requestView(request: Request): NodeRequestContext {
|
|
92
|
+
return { method: request.method, url: request.url, header: (name) => request.headers.get(name) }
|
|
93
|
+
}
|
|
94
|
+
|
|
107
95
|
/**
|
|
108
96
|
* Pretty-print JSON responses for debugging and developer-facing APIs. It only touches JSON content,
|
|
109
97
|
* skips encoded responses, caps inspection size, and leaves invalid JSON untouched.
|
|
@@ -114,8 +102,8 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
|
|
|
114
102
|
throw new Error("prettyJson: spaces must be an integer from 0 to 10")
|
|
115
103
|
}
|
|
116
104
|
const maxBytes = options.maxBytes ?? 1_000_000
|
|
117
|
-
if (!Number.
|
|
118
|
-
throw new Error("prettyJson: maxBytes must be a non-negative integer")
|
|
105
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
106
|
+
throw new Error("prettyJson: maxBytes must be a non-negative safe integer")
|
|
119
107
|
}
|
|
120
108
|
const newline = options.newline !== false
|
|
121
109
|
const query = options.query ?? false
|
|
@@ -123,37 +111,74 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
|
|
|
123
111
|
const enabled = options.enabled ?? true
|
|
124
112
|
const isEnabled = typeof enabled === "function" ? enabled : enabled ? () => true : () => false
|
|
125
113
|
|
|
114
|
+
const decoder = new TextDecoder()
|
|
115
|
+
const encoder = new TextEncoder()
|
|
126
116
|
return definePlugin("prettyJson", (app) =>
|
|
127
|
-
app.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
parsed
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
117
|
+
app.use({
|
|
118
|
+
onResponseBody(body, headers, req) {
|
|
119
|
+
if (!isEnabled(req)) return undefined
|
|
120
|
+
if (query !== false && !new URL(req.url).searchParams.has(query)) return undefined
|
|
121
|
+
if (headers.has("content-encoding")) return undefined
|
|
122
|
+
if (!isJson(headers.get("content-type") ?? "")) return undefined
|
|
123
|
+
const text = typeof body === "string" ? body : decoder.decode(body)
|
|
124
|
+
if (
|
|
125
|
+
(typeof body === "string" ? encoder.encode(body).byteLength : body.byteLength) > maxBytes
|
|
126
|
+
) {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
let parsed: unknown
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(text)
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
return `${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`
|
|
136
|
+
},
|
|
137
|
+
onResponseRaw(response, req) {
|
|
138
|
+
if (!isEnabled(requestView(req))) return response
|
|
139
|
+
if (query !== false && !new URL(req.url).searchParams.has(query)) return response
|
|
140
|
+
if (
|
|
141
|
+
response.body === null ||
|
|
142
|
+
response.status === 204 ||
|
|
143
|
+
response.status === 205 ||
|
|
144
|
+
response.status === 304 ||
|
|
145
|
+
response.headers.has("content-encoding")
|
|
146
|
+
)
|
|
147
|
+
return response
|
|
148
|
+
if (!isJson(response.headers.get("content-type") ?? "")) return response
|
|
149
|
+
const declared = response.headers.get("content-length")
|
|
150
|
+
if (declared !== null && /^(?:0|[1-9]\d*)$/.test(declared) && Number(declared) > maxBytes) {
|
|
151
|
+
return response
|
|
152
|
+
}
|
|
153
|
+
return prettyRawResponse(response, maxBytes, spaces, newline)
|
|
154
|
+
},
|
|
157
155
|
}),
|
|
158
156
|
)
|
|
159
157
|
}
|
|
158
|
+
|
|
159
|
+
async function prettyRawResponse(
|
|
160
|
+
response: Response,
|
|
161
|
+
maxBytes: number,
|
|
162
|
+
spaces: number,
|
|
163
|
+
newline: boolean,
|
|
164
|
+
): Promise<Response> {
|
|
165
|
+
const peeked = await peekText(response, maxBytes)
|
|
166
|
+
if ("response" in peeked) return peeked.response
|
|
167
|
+
let parsed: unknown
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(peeked.text)
|
|
170
|
+
} catch {
|
|
171
|
+
return new Response(peeked.bytes, {
|
|
172
|
+
status: response.status,
|
|
173
|
+
statusText: response.statusText,
|
|
174
|
+
headers: response.headers,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
const headers = new Headers(response.headers)
|
|
178
|
+
headers.delete("content-length")
|
|
179
|
+
return new Response(`${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`, {
|
|
180
|
+
status: response.status,
|
|
181
|
+
statusText: response.statusText,
|
|
182
|
+
headers,
|
|
183
|
+
})
|
|
184
|
+
}
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
-
import type { Middleware } from "@nifrajs/core/server"
|
|
2
|
+
import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
|
|
3
|
+
import { withHeaders, withNodeHeaders } from "./_utils.ts"
|
|
3
4
|
|
|
4
5
|
export interface RateLimitResult {
|
|
5
6
|
/** Hits recorded in the current window, including this one. */
|
|
@@ -184,6 +185,28 @@ function defaultKey(
|
|
|
184
185
|
return allowGlobalKey ? "global" : null
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
/** {@link defaultKey} against the allocation-light native request view - same logic, same order. */
|
|
189
|
+
function nativeKey(
|
|
190
|
+
req: NodeRequestContext,
|
|
191
|
+
trustedProxies: number,
|
|
192
|
+
header: string | undefined,
|
|
193
|
+
allowGlobalKey: boolean,
|
|
194
|
+
): string | null {
|
|
195
|
+
if (header !== undefined) {
|
|
196
|
+
const ip = req.header(header)
|
|
197
|
+
if (ip !== null && ip.trim() !== "") return ip.trim()
|
|
198
|
+
}
|
|
199
|
+
if (trustedProxies > 0) {
|
|
200
|
+
const xff = req.header("x-forwarded-for")
|
|
201
|
+
if (xff !== null) {
|
|
202
|
+
const parts = xff.split(",")
|
|
203
|
+
const ip = parts[parts.length - trustedProxies]?.trim()
|
|
204
|
+
if (ip !== undefined && ip !== "") return ip
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return allowGlobalKey ? "global" : null
|
|
208
|
+
}
|
|
209
|
+
|
|
187
210
|
/**
|
|
188
211
|
* Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
|
|
189
212
|
* also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
|
|
@@ -216,35 +239,78 @@ export function rateLimit(options: RateLimitOptions): Middleware {
|
|
|
216
239
|
const keyOf =
|
|
217
240
|
options.key ?? ((req: Request) => defaultKey(req, trustedProxies, header, allowGlobalKey))
|
|
218
241
|
const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
|
|
242
|
+
// State for the Node twins, keyed by the NodeRequestContext identity (the same object is passed
|
|
243
|
+
// to the request and response twins - the core identity contract that replaces `Request` keying).
|
|
244
|
+
const nativeQuota = new WeakMap<object, { remaining: number; resetSeconds: number }>()
|
|
219
245
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
246
|
+
interface QuotaInfo {
|
|
247
|
+
readonly remaining: number
|
|
248
|
+
readonly resetSeconds: number
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const hitStore = async (
|
|
252
|
+
key: string | null,
|
|
253
|
+
): Promise<{ info: QuotaInfo; reject: Response | undefined } | Response> => {
|
|
254
|
+
if (typeof key !== "string" || key.trim() === "") {
|
|
255
|
+
return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
|
|
256
|
+
}
|
|
257
|
+
const { count, resetAt } = await store.hit(key, windowMs)
|
|
258
|
+
const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
|
|
259
|
+
const info = { remaining: Math.max(0, max - count), resetSeconds }
|
|
260
|
+
if (count > max) {
|
|
261
|
+
return {
|
|
262
|
+
info,
|
|
263
|
+
reject: new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
|
|
232
264
|
status: 429,
|
|
233
265
|
headers: { "content-type": "application/json", "retry-after": String(resetSeconds) },
|
|
234
|
-
})
|
|
266
|
+
}),
|
|
235
267
|
}
|
|
236
|
-
|
|
268
|
+
}
|
|
269
|
+
return { info, reject: undefined }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const middleware: Middleware = {
|
|
273
|
+
name: "rate-limit",
|
|
274
|
+
async onRequest(req) {
|
|
275
|
+
const outcome = await hitStore(keyOf(req))
|
|
276
|
+
if (outcome instanceof Response) return outcome
|
|
277
|
+
quota.set(req, outcome.info)
|
|
278
|
+
return outcome.reject
|
|
237
279
|
},
|
|
238
280
|
onResponse(res, req) {
|
|
239
281
|
const info = quota.get(req)
|
|
240
282
|
if (info === undefined) return res
|
|
241
283
|
quota.delete(req)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
284
|
+
return withHeaders(res, (headers) => {
|
|
285
|
+
headers.set("RateLimit-Limit", String(max))
|
|
286
|
+
headers.set("RateLimit-Remaining", String(info.remaining))
|
|
287
|
+
headers.set("RateLimit-Reset", String(info.resetSeconds))
|
|
288
|
+
})
|
|
247
289
|
},
|
|
290
|
+
// Node twins exist only for the built-in header/XFF key derivation - a custom `key` callback
|
|
291
|
+
// takes a real `Request`, which is exactly the object the native lane avoids building.
|
|
292
|
+
...(options.key === undefined
|
|
293
|
+
? {
|
|
294
|
+
onNodeRequest: async (req: NodeRequestContext) => {
|
|
295
|
+
const outcome = await hitStore(nativeKey(req, trustedProxies, header, allowGlobalKey))
|
|
296
|
+
if (outcome instanceof Response) return outcome
|
|
297
|
+
nativeQuota.set(req, outcome.info)
|
|
298
|
+
return outcome.reject
|
|
299
|
+
},
|
|
300
|
+
onNodeResponse: (res: NodeResponseContext, req: NodeRequestContext) => {
|
|
301
|
+
const info = nativeQuota.get(req)
|
|
302
|
+
if (info === undefined) return
|
|
303
|
+
nativeQuota.delete(req)
|
|
304
|
+
withNodeHeaders(res, (headers) => {
|
|
305
|
+
// Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so
|
|
306
|
+
// this is byte-identical output.
|
|
307
|
+
headers["ratelimit-limit"] = String(max)
|
|
308
|
+
headers["ratelimit-remaining"] = String(info.remaining)
|
|
309
|
+
headers["ratelimit-reset"] = String(info.resetSeconds)
|
|
310
|
+
})
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
: {}),
|
|
248
314
|
}
|
|
249
315
|
return withRouteAssurance(middleware, {
|
|
250
316
|
id: NIFRA_ASSURANCE.RATE_LIMITED,
|
package/src/request-id.ts
CHANGED
|
@@ -22,7 +22,7 @@ export function requestId(options: RequestIdOptions = {}) {
|
|
|
22
22
|
const generate = options.generate ?? (() => crypto.randomUUID())
|
|
23
23
|
return definePlugin("requestId", (app) =>
|
|
24
24
|
app.derive((c) => {
|
|
25
|
-
const id = c.
|
|
25
|
+
const id = c.header(header) ?? generate()
|
|
26
26
|
c.set.headers[header] = id // echo on the response (route handlers)
|
|
27
27
|
return { requestId: id }
|
|
28
28
|
}),
|
package/src/security-headers.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
2
|
import type { Middleware } from "@nifrajs/core/server"
|
|
3
|
-
import { withHeaders } from "./_utils.ts"
|
|
4
3
|
|
|
5
4
|
export interface SecurityHeadersOptions {
|
|
6
5
|
/** `Strict-Transport-Security`. Off by default - opt in once you're sure you're HTTPS-only. */
|
|
@@ -18,10 +17,14 @@ export interface SecurityHeadersOptions {
|
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
|
-
* A safe-by-default set of response security headers
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
20
|
+
* A safe-by-default set of response security headers, covering errors and 404s too:
|
|
21
|
+
* `X-Content-Type-Options: nosniff`, `X-Frame-Options`, and `Referrer-Policy` always;
|
|
22
|
+
* `Strict-Transport-Security` and `Content-Security-Policy` only when configured (both are
|
|
23
|
+
* environment-/app-specific).
|
|
24
|
+
*
|
|
25
|
+
* Every value is fixed at construction, so these are declared statically rather than written by a
|
|
26
|
+
* response hook: an app whose response middleware is only this keeps the fused native lanes. A route
|
|
27
|
+
* that sets one of these names itself keeps its own value.
|
|
25
28
|
*/
|
|
26
29
|
export function securityHeaders(options: SecurityHeadersOptions = {}): Middleware {
|
|
27
30
|
const frameOptions = options.frameOptions ?? "DENY"
|
|
@@ -36,19 +39,18 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
|
|
|
36
39
|
hstsValue = parts.join("; ")
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
const declared: Record<string, string> = {
|
|
43
|
+
"x-content-type-options": "nosniff",
|
|
44
|
+
"x-frame-options": frameOptions,
|
|
45
|
+
"referrer-policy": referrerPolicy,
|
|
46
|
+
}
|
|
47
|
+
if (hstsValue !== undefined) declared["strict-transport-security"] = hstsValue
|
|
48
|
+
if (csp !== undefined) declared["content-security-policy"] = csp
|
|
49
|
+
|
|
39
50
|
return withRouteAssurance<Middleware>(
|
|
40
51
|
{
|
|
41
52
|
name: "security-headers",
|
|
42
|
-
|
|
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
|
-
}),
|
|
53
|
+
responseHeaders: declared,
|
|
52
54
|
},
|
|
53
55
|
{
|
|
54
56
|
id: NIFRA_ASSURANCE.SECURITY_HEADERS,
|