@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.6

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.
Files changed (65) hide show
  1. package/README.md +34 -22
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +42 -7
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +117 -46
  9. package/dist/app.d.ts +30 -4
  10. package/dist/app.js +187 -45
  11. package/dist/auto-ban.js +1 -3
  12. package/dist/bot-guard.js +30 -3
  13. package/dist/cli.js +9 -6
  14. package/dist/client.d.ts +36 -7
  15. package/dist/client.js +7 -0
  16. package/dist/compression.d.ts +9 -0
  17. package/dist/compression.js +72 -1
  18. package/dist/config.js +1 -3
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/errors.d.ts +12 -3
  22. package/dist/errors.js +14 -8
  23. package/dist/etag.js +12 -2
  24. package/dist/fetch-guard.d.ts +27 -19
  25. package/dist/fetch-guard.js +50 -8
  26. package/dist/geo-block.js +4 -9
  27. package/dist/hashing.js +1 -1
  28. package/dist/http-signatures.d.ts +4 -1
  29. package/dist/http-signatures.js +16 -9
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.js +3 -3
  32. package/dist/ip-reputation.js +1 -1
  33. package/dist/ip-restriction.js +3 -12
  34. package/dist/jwt.js +12 -14
  35. package/dist/logger.d.ts +45 -0
  36. package/dist/logger.js +135 -0
  37. package/dist/mcp.js +10 -9
  38. package/dist/middleware.js +33 -3
  39. package/dist/mtls.js +6 -1
  40. package/dist/multipart.js +9 -12
  41. package/dist/openapi.d.ts +1 -1
  42. package/dist/openapi.js +2 -2
  43. package/dist/rate-limit-redis.d.ts +4 -4
  44. package/dist/response-cache.d.ts +179 -21
  45. package/dist/response-cache.js +338 -29
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +5 -1
  49. package/dist/safe-redirect.js +27 -3
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security-schemes.js +1 -2
  53. package/dist/security.d.ts +41 -0
  54. package/dist/security.js +131 -15
  55. package/dist/session.d.ts +13 -2
  56. package/dist/session.js +111 -17
  57. package/dist/subdomains.js +1 -4
  58. package/dist/tenancy.d.ts +40 -0
  59. package/dist/tenancy.js +54 -3
  60. package/dist/time-claims.js +3 -1
  61. package/dist/waf.js +124 -32
  62. package/dist/webhook-delivery.js +19 -3
  63. package/dist/websocket.d.ts +8 -0
  64. package/dist/websocket.js +19 -4
  65. package/package.json +6 -5
@@ -217,6 +217,57 @@ function normalizeOptionTokens(values, optionName) {
217
217
  }
218
218
  return Object.freeze(normalized);
219
219
  }
220
+ /**
221
+ * Read a response body up to `maxBytes`. Returns `null` if the stream
222
+ * exceeds the cap (body is cancelled; caller should leave the response
223
+ * uncompressed). Returns an empty buffer when there is no body.
224
+ *
225
+ * @param res - Response whose body will be consumed (pass a clone).
226
+ * @param maxBytes - Inclusive upper bound on buffered size.
227
+ */
228
+ async function readBodyUpTo(res, maxBytes) {
229
+ if (!res.body)
230
+ return new Uint8Array(0);
231
+ const reader = res.body.getReader();
232
+ const chunks = [];
233
+ let total = 0;
234
+ try {
235
+ // eslint-disable-next-line no-constant-condition
236
+ while (true) {
237
+ const { done, value } = await reader.read();
238
+ if (done)
239
+ break;
240
+ if (!value || value.byteLength === 0)
241
+ continue;
242
+ total += value.byteLength;
243
+ if (total > maxBytes) {
244
+ await reader.cancel();
245
+ return null;
246
+ }
247
+ chunks.push(value);
248
+ }
249
+ }
250
+ catch {
251
+ try {
252
+ await reader.cancel();
253
+ }
254
+ catch {
255
+ /* ignore */
256
+ }
257
+ return null;
258
+ }
259
+ if (chunks.length === 0)
260
+ return new Uint8Array(0);
261
+ if (chunks.length === 1)
262
+ return chunks[0];
263
+ const out = new Uint8Array(total);
264
+ let offset = 0;
265
+ for (const c of chunks) {
266
+ out.set(c, offset);
267
+ offset += c.byteLength;
268
+ }
269
+ return out;
270
+ }
220
271
  async function compressBytes(bytes, encoding) {
221
272
  const Stream = globalThis.CompressionStream;
222
273
  const cs = new Stream(encoding);
@@ -297,6 +348,16 @@ export function compression(opts = {}) {
297
348
  minimumSize > 2 ** 31 - 1) {
298
349
  throw new TypeError("compression(): `minimumSize` must be a finite non-negative integer.");
299
350
  }
351
+ const maxCompressibleBytes = opts.maxCompressibleBytes === undefined ? 1_048_576 : opts.maxCompressibleBytes;
352
+ if (!Number.isFinite(maxCompressibleBytes) ||
353
+ !Number.isInteger(maxCompressibleBytes) ||
354
+ maxCompressibleBytes <= 0 ||
355
+ maxCompressibleBytes > 2 ** 31 - 1) {
356
+ throw new TypeError("compression(): `maxCompressibleBytes` must be a positive integer <= 2**31-1.");
357
+ }
358
+ if (minimumSize > maxCompressibleBytes) {
359
+ throw new TypeError("compression(): `minimumSize` must not exceed `maxCompressibleBytes`.");
360
+ }
300
361
  const serverPreferred = opts.encodings && opts.encodings.length > 0
301
362
  ? Object.freeze([...opts.encodings])
302
363
  : Object.freeze(["br", "gzip", "deflate"]);
@@ -338,7 +399,17 @@ export function compression(opts = {}) {
338
399
  const chosen = pickEncoding(accept, serverPreferred, runtimeSupported);
339
400
  if (!chosen)
340
401
  return undefined;
341
- const original = new Uint8Array(await res.clone().arrayBuffer());
402
+ // Fast-path skip when Content-Length already exceeds the compress cap
403
+ // (avoids buffering a known-huge body just to discard it).
404
+ const declaredLength = res.headers.get("content-length");
405
+ if (declaredLength !== null) {
406
+ const n = Number(declaredLength);
407
+ if (Number.isFinite(n) && n > maxCompressibleBytes)
408
+ return undefined;
409
+ }
410
+ const original = await readBodyUpTo(res.clone(), maxCompressibleBytes);
411
+ if (original === null)
412
+ return undefined; // exceeded cap while streaming
342
413
  if (original.byteLength < minimumSize)
343
414
  return undefined;
344
415
  const compressed = await compressBytes(original, chosen);
package/dist/config.js CHANGED
@@ -24,9 +24,7 @@ export class ConfigValidationError extends Error {
24
24
  /** Every validation issue, as `{ key, message }` pairs (`key` is the dotted path, `"<root>"`/`"<source>"` for top-level failures). */
25
25
  issues;
26
26
  constructor(issues) {
27
- const summary = issues
28
- .map((i) => ` - ${i.key || "<root>"}: ${i.message}`)
29
- .join("\n");
27
+ const summary = issues.map((i) => ` - ${i.key || "<root>"}: ${i.message}`).join("\n");
30
28
  super(`defineConfig(): configuration is invalid (${issues.length} issue${issues.length === 1 ? "" : "s"})\n${summary}`);
31
29
  this.name = "ConfigValidationError";
32
30
  this.issues = issues;
@@ -67,8 +67,11 @@ interface MutableConnInfo {
67
67
  }
68
68
  /**
69
69
  * @internal Adapter helper — attach {@link ConnInfo} to a `Request`. Called
70
- * by the Node / Bun / Deno / Cloudflare / Vercel / Lambda adapters before
71
- * `app.fetch(request)`.
70
+ * by the Node / Bun / Deno / Lambda adapters before `app.fetch(request)`.
71
+ * The pure edge delegators (Cloudflare, Vercel, Fastly) expose no peer
72
+ * socket to attach — on those platforms the client address arrives via
73
+ * platform-set headers, which are governed by the `behindProxy` /
74
+ * `trustProxyHeaders` policies instead.
72
75
  *
73
76
  * @param request - Incoming request to tag (stored under a private symbol).
74
77
  * @param info - Connection metadata gathered by the adapter.
package/dist/conn-info.js CHANGED
@@ -21,8 +21,11 @@
21
21
  const CONN_INFO_SYMBOL = Symbol.for("daloyjs.connInfo");
22
22
  /**
23
23
  * @internal Adapter helper — attach {@link ConnInfo} to a `Request`. Called
24
- * by the Node / Bun / Deno / Cloudflare / Vercel / Lambda adapters before
25
- * `app.fetch(request)`.
24
+ * by the Node / Bun / Deno / Lambda adapters before `app.fetch(request)`.
25
+ * The pure edge delegators (Cloudflare, Vercel, Fastly) expose no peer
26
+ * socket to attach — on those platforms the client address arrives via
27
+ * platform-set headers, which are governed by the `behindProxy` /
28
+ * `trustProxyHeaders` policies instead.
26
29
  *
27
30
  * @param request - Incoming request to tag (stored under a private symbol).
28
31
  * @param info - Connection metadata gathered by the adapter.
package/dist/errors.d.ts CHANGED
@@ -364,9 +364,18 @@ export declare class TooManyRequestsError extends HttpError {
364
364
  }
365
365
  /**
366
366
  * `408 Request Timeout` — thrown when a handler exceeds
367
- * {@link AppOptions.requestTimeoutMs}. The framework aborts the in-flight
368
- * handler when this fires (handlers should respect the `AbortSignal` on
369
- * `ctx.request.signal` to clean up).
367
+ * {@link AppOptions.requestTimeoutMs}.
368
+ *
369
+ * When the timeout fires the framework aborts `ctx.request.signal` (with a
370
+ * `TimeoutError` reason) so a handler that forwarded that signal to downstream
371
+ * I/O — `fetch`, a DB driver — sees those calls reject and can unwind. It does
372
+ * **not** forcibly terminate the handler: single-threaded JS cannot preempt
373
+ * running code, so CPU-bound or non-cooperative work continues in the
374
+ * background until it observes the aborted signal or finishes. Forward
375
+ * `ctx.request.signal` into every cancellable downstream call to get the
376
+ * benefit. (Signal firing is wired on the Node adapter and any runtime whose
377
+ * request shim honors the abort hook; direct `app.fetch()` callers still get
378
+ * the `408` but no signal abort.)
370
379
  *
371
380
  * @param ms - The configured timeout that was exceeded.
372
381
  * @since 0.1.0
package/dist/errors.js CHANGED
@@ -40,9 +40,7 @@ export class MessageLeakError extends Error {
40
40
  /** The refused headers, each with its name and the reason it was disallowed. */
41
41
  offendingHeaders;
42
42
  constructor(offendingHeaders) {
43
- const summary = offendingHeaders
44
- .map((h) => `${h.name} (${h.reason})`)
45
- .join(", ");
43
+ const summary = offendingHeaders.map((h) => `${h.name} (${h.reason})`).join(", ");
46
44
  super(`httpError({ res }): custom error response carries disallowed header(s): ${summary}. ` +
47
45
  "Only WWW-Authenticate, Proxy-Authenticate, Retry-After, Content-Type, " +
48
46
  "Content-Language, Content-Length, and Cache-Control (no-store|no-cache) " +
@@ -212,8 +210,7 @@ export class HttpError extends Error {
212
210
  * @returns A `Response` with `Content-Type: application/problem+json`.
213
211
  */
214
212
  toResponse(opts = {}) {
215
- const isProd = opts.production ??
216
- (typeof process !== "undefined" && process.env?.NODE_ENV === "production");
213
+ const isProd = opts.production ?? (typeof process !== "undefined" && process.env?.NODE_ENV === "production");
217
214
  const out = { ...this.problem };
218
215
  if (isProd && this.status >= 500) {
219
216
  delete out.detail; // do not leak internals
@@ -483,9 +480,18 @@ export class TooManyRequestsError extends HttpError {
483
480
  }
484
481
  /**
485
482
  * `408 Request Timeout` — thrown when a handler exceeds
486
- * {@link AppOptions.requestTimeoutMs}. The framework aborts the in-flight
487
- * handler when this fires (handlers should respect the `AbortSignal` on
488
- * `ctx.request.signal` to clean up).
483
+ * {@link AppOptions.requestTimeoutMs}.
484
+ *
485
+ * When the timeout fires the framework aborts `ctx.request.signal` (with a
486
+ * `TimeoutError` reason) so a handler that forwarded that signal to downstream
487
+ * I/O — `fetch`, a DB driver — sees those calls reject and can unwind. It does
488
+ * **not** forcibly terminate the handler: single-threaded JS cannot preempt
489
+ * running code, so CPU-bound or non-cooperative work continues in the
490
+ * background until it observes the aborted signal or finishes. Forward
491
+ * `ctx.request.signal` into every cancellable downstream call to get the
492
+ * benefit. (Signal firing is wired on the Node adapter and any runtime whose
493
+ * request shim honors the abort hook; direct `app.fetch()` callers still get
494
+ * the `408` but no signal abort.)
489
495
  *
490
496
  * @param ms - The configured timeout that was exceeded.
491
497
  * @since 0.1.0
package/dist/etag.js CHANGED
@@ -38,7 +38,10 @@ async function sha1Hex(bytes) {
38
38
  }
39
39
  function inmMatches(headerValue, candidate) {
40
40
  // RFC 7232 §3.2: comma-separated list of entity tags or `*`.
41
- const list = headerValue.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
41
+ const list = headerValue
42
+ .split(",")
43
+ .map((s) => s.trim())
44
+ .filter((s) => s.length > 0);
42
45
  if (list.length === 0)
43
46
  return false;
44
47
  for (const tag of list) {
@@ -97,7 +100,14 @@ export function etag(opts = {}) {
97
100
  const inm = ctx?.request?.headers.get("if-none-match");
98
101
  if (inm && inmMatches(inm, value)) {
99
102
  const stripped = new Headers();
100
- for (const allow of ["cache-control", "content-location", "date", "etag", "expires", "vary"]) {
103
+ for (const allow of [
104
+ "cache-control",
105
+ "content-location",
106
+ "date",
107
+ "etag",
108
+ "expires",
109
+ "vary",
110
+ ]) {
101
111
  const v = headers.get(allow);
102
112
  if (v !== null)
103
113
  stripped.set(allow, v);
@@ -54,13 +54,14 @@
54
54
  * a `127.0.0.1` / `169.254.169.254` at connect time, slipping past the
55
55
  * library-level check. To close the window:
56
56
  *
57
- * 0. **Built-in, `http:` only** (recommended for cloud-metadata defense):
58
- * set {@link FetchGuardOptions.pinDns} `: true`. On Node, `http:`
59
- * requests are then dispatched through `node:http` with the socket
60
- * pinned to the validated IP (and the original `Host` header
61
- * preserved), so there is no connect-time re-resolution to rebind.
62
- * `https:` is not pinned by this knob see its docs — so the items
63
- * below still matter for TLS upstreams.
57
+ * 0. **Built-in, `http:` only** (default on Node):
58
+ * {@link FetchGuardOptions.pinDns} defaults to `true` on Node-like
59
+ * runtimes (and `false` elsewhere). On Node, `http:` requests are
60
+ * then dispatched through `node:http` with the socket pinned to the
61
+ * validated IP (and the original `Host` header preserved), so there
62
+ * is no connect-time re-resolution to rebind. Set `pinDns: false` to
63
+ * opt out. `https:` is not pinned by this knob — see its docs — so
64
+ * the items below still matter for TLS upstreams.
64
65
  * 1. **Operator-side** (recommended): block egress to RFC1918 /
65
66
  * loopback / link-local at the VPC / firewall layer. This neutralises
66
67
  * the rebinding even if the application is naïve.
@@ -93,7 +94,7 @@
93
94
  *
94
95
  * @since 0.34.0
95
96
  */
96
- export type SsrfBlockReason = "protocol-not-allowed" | "host-not-allowed" | "dns-resolution-failed" | "address-not-allowed" | "too-many-redirects" | "invalid-url";
97
+ export type SsrfBlockReason = "protocol-not-allowed" | "host-not-allowed" | "dns-resolution-failed" | "address-not-allowed" | "too-many-redirects" | "credentials-in-url" | "invalid-url";
97
98
  /**
98
99
  * Thrown by {@link fetchGuard} when an outbound request is refused. Never
99
100
  * thrown for ordinary network failures — those bubble through unchanged
@@ -188,14 +189,20 @@ export interface FetchGuardOptions {
188
189
  * by connecting the socket to the exact IP that was validated, instead of
189
190
  * letting the underlying client re-resolve the hostname at connect time.
190
191
  *
191
- * When `true` (default `false`), a request to a hostname that resolves to a
192
- * validated address is dispatched through Node's built-in `node:http` with
193
- * the connection pinned to that address and the original `Host` header
194
- * preserved — so virtual-host routing still works while an attacker's
195
- * TTL=0 rebinding to `127.0.0.1` / `169.254.169.254` can no longer take
196
- * effect between validation and connect.
192
+ * When `true`, a request to a hostname that resolves to a validated address
193
+ * is dispatched through Node's built-in `node:http` with the connection
194
+ * pinned to that address and the original `Host` header preserved — so
195
+ * virtual-host routing still works while an attacker's TTL=0 rebinding to
196
+ * `127.0.0.1` / `169.254.169.254` can no longer take effect between
197
+ * validation and connect.
197
198
  *
198
- * **Scope and caveats** (read before enabling):
199
+ * **Default:** `true` on Node-like runtimes (`process.versions.node` is a
200
+ * non-empty string), `false` elsewhere (Workers / edge sandboxes without
201
+ * `node:http`). Pass `pinDns: false` to opt out on Node, or `pinDns: true`
202
+ * on a non-Node runtime only if you can tolerate the loud error when the
203
+ * pin path cannot load `node:http`.
204
+ *
205
+ * **Scope and caveats** (read before changing the default):
199
206
  *
200
207
  * - **`http:` only.** `https:` is intentionally NOT pinned here: pinning a
201
208
  * TLS connection to an IP while keeping hostname-based SNI / certificate
@@ -204,16 +211,17 @@ export interface FetchGuardOptions {
204
211
  * the documented TOCTOU caveat. The prime rebinding target — cloud
205
212
  * metadata at `http://169.254.169.254` — is `http:`, so this still closes
206
213
  * the highest-value vector.
207
- * - **Node only.** It uses `node:http`; on runtimes without it (Workers,
208
- * some edge sandboxes) an `http:` pinned dispatch throws a clear error so
209
- * the misconfiguration is loud rather than a silent no-op.
214
+ * - **Node only for the pin path.** It uses `node:http`; when `pinDns` is
215
+ * explicitly `true` on a runtime without it, an `http:` pinned dispatch
216
+ * throws a clear error so the misconfiguration is loud rather than a
217
+ * silent no-op.
210
218
  * - **Bypasses `options.fetch`** for the pinned `http:` path (it must own the
211
219
  * socket), and negotiates no response compression (`Accept-Encoding:
212
220
  * identity`) so body semantics match a plain `fetch`.
213
221
  *
214
222
  * Requests to a literal-IP host or an `allowHosts` entry are never pinned
215
223
  * (the former already connects to an exact IP; the latter is an explicit
216
- * operator trust). Default `false`.
224
+ * operator trust).
217
225
  *
218
226
  * @since 0.44.0
219
227
  */
@@ -54,13 +54,14 @@
54
54
  * a `127.0.0.1` / `169.254.169.254` at connect time, slipping past the
55
55
  * library-level check. To close the window:
56
56
  *
57
- * 0. **Built-in, `http:` only** (recommended for cloud-metadata defense):
58
- * set {@link FetchGuardOptions.pinDns} `: true`. On Node, `http:`
59
- * requests are then dispatched through `node:http` with the socket
60
- * pinned to the validated IP (and the original `Host` header
61
- * preserved), so there is no connect-time re-resolution to rebind.
62
- * `https:` is not pinned by this knob see its docs — so the items
63
- * below still matter for TLS upstreams.
57
+ * 0. **Built-in, `http:` only** (default on Node):
58
+ * {@link FetchGuardOptions.pinDns} defaults to `true` on Node-like
59
+ * runtimes (and `false` elsewhere). On Node, `http:` requests are
60
+ * then dispatched through `node:http` with the socket pinned to the
61
+ * validated IP (and the original `Host` header preserved), so there
62
+ * is no connect-time re-resolution to rebind. Set `pinDns: false` to
63
+ * opt out. `https:` is not pinned by this knob — see its docs — so
64
+ * the items below still matter for TLS upstreams.
64
65
  * 1. **Operator-side** (recommended): block egress to RFC1918 /
65
66
  * loopback / link-local at the VPC / firewall layer. This neutralises
66
67
  * the rebinding even if the application is naïve.
@@ -112,6 +113,20 @@ export class SsrfBlockedError extends Error {
112
113
  this.address = address;
113
114
  }
114
115
  }
116
+ /**
117
+ * Whether this runtime looks like Node (or a Node-compatible host such as Bun)
118
+ * where `node:http` DNS pinning is available.
119
+ *
120
+ * Used as the default for {@link FetchGuardOptions.pinDns} so Node apps get
121
+ * rebinding defense without an opt-in, while Workers / pure edge runtimes keep
122
+ * the non-pinning path.
123
+ */
124
+ function defaultPinDnsEnabled() {
125
+ return (typeof process !== "undefined" &&
126
+ process.versions != null &&
127
+ typeof process.versions.node === "string" &&
128
+ process.versions.node.length > 0);
129
+ }
115
130
  // Always-on deny matchers. No option flips these.
116
131
  const ALWAYS_DENY = [
117
132
  "0.0.0.0/8", // "this network"
@@ -185,7 +200,11 @@ export function fetchGuard(options = {}) {
185
200
  throw new Error("fetchGuard(): no global fetch available; pass options.fetch.");
186
201
  }
187
202
  const resolveFn = options.resolve ?? createDefaultResolver();
188
- const pinDns = options.pinDns === true;
203
+ // Secure default on Node when using the built-in fetch: pin http: sockets
204
+ // to the validated IP. A custom `options.fetch` owns its own socket / DNS
205
+ // policy, so pinDns stays off unless the caller opts in. Non-Node runtimes
206
+ // default off (no node:http). Opt out on Node with `pinDns: false`.
207
+ const pinDns = options.pinDns ?? (options.fetch === undefined && defaultPinDnsEnabled());
189
208
  for (const c of ALWAYS_DENY)
190
209
  hardDenyMatchers.push(compileCidrMatcher(c));
191
210
  for (const c of options.denyAddresses ?? [])
@@ -269,6 +288,29 @@ export function fetchGuard(options = {}) {
269
288
  return addrs[0];
270
289
  }
271
290
  const guarded = async (input, init) => {
291
+ // A URL carrying userinfo (`http://user:pass@internal/`) is a classic SSRF
292
+ // obfuscation — the real host hides after the `@`. undici's `Request`
293
+ // constructor refuses such URLs with a raw `TypeError`, which would fire
294
+ // *before* our host validation and escape the `SsrfBlockedError` contract,
295
+ // so callers misclassify a blocked SSRF attempt as an ordinary upstream
296
+ // failure. Detect and refuse it ourselves with a typed error first. The
297
+ // credentials are stripped from the URL recorded on the error so a
298
+ // caller-supplied secret never leaks into logs. Malformed URLs fall
299
+ // through to the handling below, which raises `SsrfBlockedError("invalid-url")`.
300
+ if (typeof input === "string" || input instanceof URL) {
301
+ let pre;
302
+ try {
303
+ pre = new URL(input);
304
+ }
305
+ catch {
306
+ pre = undefined;
307
+ }
308
+ if (pre && (pre.username !== "" || pre.password !== "")) {
309
+ pre.username = "";
310
+ pre.password = "";
311
+ throw new SsrfBlockedError(pre.toString(), "credentials-in-url");
312
+ }
313
+ }
272
314
  let request = new Request(input, init);
273
315
  const userRedirect = (init?.redirect ?? request.redirect);
274
316
  // Always dispatch underlying calls with redirect: "manual" so we can
package/dist/geo-block.js CHANGED
@@ -90,12 +90,10 @@ export function geoBlock(opts) {
90
90
  const hasLookup = typeof opts.lookupCountry === "function";
91
91
  const hasResolve = typeof opts.resolveCountry === "function";
92
92
  if (hasLookup === hasResolve) {
93
- throw new Error('geoBlock(): exactly one of "lookupCountry" or "resolveCountry" must ' +
94
- "be provided.");
93
+ throw new Error('geoBlock(): exactly one of "lookupCountry" or "resolveCountry" must ' + "be provided.");
95
94
  }
96
95
  if (opts.mode !== undefined && opts.mode !== "block" && opts.mode !== "log") {
97
- throw new Error(`geoBlock(): invalid mode ${JSON.stringify(opts.mode)}; expected ` +
98
- '"block" or "log".');
96
+ throw new Error(`geoBlock(): invalid mode ${JSON.stringify(opts.mode)}; expected ` + '"block" or "log".');
99
97
  }
100
98
  const allow = new Set((opts.allow ?? []).map(normalizeConfiguredCode));
101
99
  const deny = new Set((opts.deny ?? []).map(normalizeConfiguredCode));
@@ -107,8 +105,7 @@ export function geoBlock(opts) {
107
105
  const onBlock = opts.onBlock;
108
106
  const lookupCountry = opts.lookupCountry;
109
107
  const resolveCountry = opts.resolveCountry;
110
- const resolveIp = opts.resolveIp ??
111
- (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
108
+ const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
112
109
  return {
113
110
  async beforeHandle(ctx) {
114
111
  let ip;
@@ -120,9 +117,7 @@ export function geoBlock(opts) {
120
117
  ip = resolveIp(ctx) ?? undefined;
121
118
  rawCountry = ip ? await lookupCountry(ip) : undefined;
122
119
  }
123
- const country = rawCountry && rawCountry.trim()
124
- ? rawCountry.trim().toUpperCase()
125
- : undefined;
120
+ const country = rawCountry && rawCountry.trim() ? rawCountry.trim().toUpperCase() : undefined;
126
121
  let reason;
127
122
  if (!country) {
128
123
  if (!allowUnknown)
package/dist/hashing.js CHANGED
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * @since 0.15.0
24
24
  */
25
- import { randomBytes, scrypt as scryptCb, timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
25
+ import { randomBytes, scrypt as scryptCb, timingSafeEqual as nodeTimingSafeEqual, } from "node:crypto";
26
26
  // OWASP-aligned scrypt parameters (Password Storage Cheat Sheet, 2024).
27
27
  const SCRYPT_N = 1 << 17; // 131072
28
28
  const SCRYPT_R = 8;
@@ -229,7 +229,10 @@ export interface VerifyMessageOptions {
229
229
  label?: string;
230
230
  /**
231
231
  * Component identifiers that MUST be covered. Defaults to
232
- * `["@method", "@path"]`. Pass `[]` to disable the check (not recommended).
232
+ * `["@method", "@target-uri"]` so the verifier binds scheme, authority,
233
+ * path, **and query** (matching {@link signMessage}'s default covered set).
234
+ * Prefer this over bare `@path`, which leaves query parameters unsigned.
235
+ * Pass `[]` to disable the check (not recommended).
233
236
  */
234
237
  requiredComponents?: string[];
235
238
  /** Require the `created` parameter. Defaults to `true`. */
@@ -61,8 +61,7 @@ const ENC = new TextEncoder();
61
61
  // WebCrypto + encoding helpers
62
62
  // ---------------------------------------------------------------------------
63
63
  function getCrypto() {
64
- const c = globalThis
65
- .crypto;
64
+ const c = globalThis.crypto;
66
65
  if (!c?.subtle) {
67
66
  throw new Error("http-signatures: WebCrypto SubtleCrypto API is unavailable on this runtime.");
68
67
  }
@@ -181,9 +180,7 @@ async function importKey(alg, material, usage) {
181
180
  return c.subtle.importKey("raw", material, spec.importParams, false, [usage]);
182
181
  }
183
182
  if (isJsonWebKey(material)) {
184
- const key = await c.subtle.importKey("jwk", material, spec.importParams, false, [
185
- usage,
186
- ]);
183
+ const key = await c.subtle.importKey("jwk", material, spec.importParams, false, [usage]);
187
184
  assertRsaModulusFloor(alg, key);
188
185
  return key;
189
186
  }
@@ -435,6 +432,15 @@ function resolveComponentValue(c, msg) {
435
432
  if (values.length === 0) {
436
433
  throw new ComponentError(`@query-param;name="${c.paramName}" is not present in the query`);
437
434
  }
435
+ // Reject multi-value params: signing only the first value while an app
436
+ // or intermediary uses the last value (or the full array) is a classic
437
+ // HTTP parameter-pollution differential. Prefer `@query` / `@target-uri`
438
+ // when multiple values are legitimate.
439
+ if (values.length > 1) {
440
+ throw new ComponentError(`@query-param;name="${c.paramName}" appears ${values.length} times; ` +
441
+ "duplicate query parameters are not supported (parameter pollution risk). " +
442
+ "Cover `@query` or `@target-uri` instead, or send a single value.");
443
+ }
438
444
  return values[0];
439
445
  }
440
446
  case "@status":
@@ -631,7 +637,10 @@ export async function verifyMessage(opts) {
631
637
  ...(params.tag !== undefined ? { tag: params.tag } : {}),
632
638
  };
633
639
  // Required components.
634
- const requiredComponents = opts.requiredComponents ?? ["@method", "@path"];
640
+ // Align with signMessage()'s default covered components so a default sign
641
+ // is accepted by a default verify, and so query/authority cannot be swapped
642
+ // out under a signature that only bound `@path`.
643
+ const requiredComponents = opts.requiredComponents ?? ["@method", "@target-uri"];
635
644
  const coveredIds = input.components.map(serializeComponentId);
636
645
  for (const req of requiredComponents) {
637
646
  const wanted = serializeComponentId(parseComponentSpec(req));
@@ -674,9 +683,7 @@ export async function verifyMessage(opts) {
674
683
  return fail("key_not_found");
675
684
  let keyMaterial;
676
685
  let pinnedAlg;
677
- if (resolved instanceof Uint8Array ||
678
- isCryptoKey(resolved) ||
679
- isJsonWebKey(resolved)) {
686
+ if (resolved instanceof Uint8Array || isCryptoKey(resolved) || isJsonWebKey(resolved)) {
680
687
  keyMaterial = resolved;
681
688
  }
682
689
  else {
package/dist/index.d.ts CHANGED
@@ -74,7 +74,7 @@ export { defineConfig, ConfigValidationError } from "./config.js";
74
74
  export type { ConfigSource, DefineConfigOptions } from "./config.js";
75
75
  export type { RequestIdOptions, SecureHeadersOptions, CspDirectivesOptions, CorsOptions, CorsOriginAllow, RateLimitOptions, RateLimitContext, RateLimitStore, LoginThrottleOptions, CsrfOptions, CsrfCookieOptions, FetchMetadataOptions, BasicAuthOptions, } from "./middleware.js";
76
76
  export type { BearerAuthOptions, BearerAuthVerifyHook } from "./middleware.js";
77
- export { createLogger, noopLogger, DEFAULT_REDACT_KEYS } from "./logger.js";
77
+ export { createLogger, noopLogger, DEFAULT_REDACT_KEYS, SENSITIVE_URL_QUERY_KEYS, sanitizeUrlForLog, } from "./logger.js";
78
78
  export type { Logger, LogLevel, ConsoleLoggerOptions, LoggerRedactionOptions } from "./logger.js";
79
79
  export type { ScalarJsonPrimitive, ScalarJsonValue, ScalarReferenceConfiguration, ScalarTheme, RedocConfiguration, RedocHtmlOptions, SwaggerUiConfiguration, SwaggerUiHtmlOptions, AsyncApiHtmlOptions, DocsAssetOptions, DocsAuthLauncherOptions, } from "./docs.js";
80
80
  export { formatStartupBanner, printStartupBanner } from "./banner.js";
@@ -89,7 +89,7 @@ export { session, rotateSession, signValue, verifySignedValue, MemorySessionStor
89
89
  export type { SessionOptions, SessionCookieOptions, SessionContext, SessionRecord, SessionStore, SessionState, RotateSessionOptions, } from "./session.js";
90
90
  export { idempotency, MemoryIdempotencyStore, _resetSharedIdempotencyStoresForTests, } from "./idempotency.js";
91
91
  export type { IdempotencyOptions, IdempotencyStore, IdempotencyRecord, StoredIdempotentResponse, } from "./idempotency.js";
92
- export { responseCache, MemoryResponseCacheStore, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
92
+ export { responseCache, MemoryResponseCacheStore, RESPONSE_CACHE_HOOK_MARKER, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
93
93
  export type { ResponseCacheOptions, ResponseCacheStore, CachedResponse } from "./response-cache.js";
94
94
  export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, paginationQuery, MAX_CURSOR_LENGTH, } from "./pagination.js";
95
95
  export type { PaginationLink, PageLinkOptions, PageLinks, PaginationQueryOptions, PaginationParams, PaginationQuerySchema, } from "./pagination.js";
@@ -99,7 +99,7 @@ export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema,
99
99
  export type { FileFieldSchema, FileFieldOptions, FileMagicBytesOption, FileMagicBytesSignature, MultipartObjectOptions, MultipartShape, UploadedFile, } from "./multipart.js";
100
100
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
101
101
  export type { OtelTracingOptions, TracingAttributes, TracingAttributeValue, TracingSpan, TracingStartSpanOptions, TracingTracer, } from "./tracing.js";
102
- export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, } from "./tenancy.js";
102
+ export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
103
103
  export type { TenancyOptions, TenantResolver, TenantScopeOptions, SubdomainTenantOptions, PathPrefixTenantOptions, ClaimTenantOptions, UnresolvedStatus, InvalidStatus, } from "./tenancy.js";
104
104
  export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
105
105
  export type { WebSocketConnection, WebSocketContext, WebSocketHandler, WebSocketMeta, WebSocketRouteEntry, NormalizedWebSocketOptions, WebSocketBeforeUpgrade, HandshakeResult, ParsedFrame, MessageEvent as WebSocketMessageEvent, FrameSinkEvents, } from "./websocket.js";
package/dist/index.js CHANGED
@@ -38,17 +38,17 @@ export { waf } from "./waf.js";
38
38
  export { safeRedirect, OpenRedirectBlockedError } from "./safe-redirect.js";
39
39
  export { loadShedding, LOAD_SHEDDING_MARKER } from "./load-shedding.js";
40
40
  export { defineConfig, ConfigValidationError } from "./config.js";
41
- export { createLogger, noopLogger, DEFAULT_REDACT_KEYS } from "./logger.js";
41
+ export { createLogger, noopLogger, DEFAULT_REDACT_KEYS, SENSITIVE_URL_QUERY_KEYS, sanitizeUrlForLog, } from "./logger.js";
42
42
  export { formatStartupBanner, printStartupBanner } from "./banner.js";
43
43
  export { sseStream, sseResponse, ndjsonStream, ndjsonResponse } from "./streaming.js";
44
44
  export { httpBearerScheme, httpBasicScheme, apiKeyScheme, oauth2Scheme, openIdConnectScheme, REQUIRE_PAYLOAD_AUTH_EXTENSION, securitySchemeRequiresPayloadAuth, toOpenAPISecurityScheme, } from "./security-schemes.js";
45
45
  export { discriminator, discriminatedUnion } from "./discriminator.js";
46
46
  export { session, rotateSession, signValue, verifySignedValue, MemorySessionStore, SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
47
47
  export { idempotency, MemoryIdempotencyStore, _resetSharedIdempotencyStoresForTests, } from "./idempotency.js";
48
- export { responseCache, MemoryResponseCacheStore, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
48
+ export { responseCache, MemoryResponseCacheStore, RESPONSE_CACHE_HOOK_MARKER, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
49
49
  export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, paginationQuery, MAX_CURSOR_LENGTH, } from "./pagination.js";
50
50
  export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
51
51
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
52
52
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
53
- export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, } from "./tenancy.js";
53
+ export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
54
54
  export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
@@ -46,7 +46,7 @@
46
46
  */
47
47
  import { ForbiddenError } from "./errors.js";
48
48
  import { fetchGuard } from "./fetch-guard.js";
49
- import { compileCidrMatcher, matchesMatcher, parseIp, } from "./ip-restriction.js";
49
+ import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-restriction.js";
50
50
  const DEFAULT_REFRESH_MS = 60 * 60_000;
51
51
  const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
52
52
  const DEFAULT_MESSAGE = "IP address not permitted";
@@ -40,8 +40,7 @@ export function ipRestriction(opts) {
40
40
  }
41
41
  const allow = (opts.allow ?? []).map(compileCidrMatcher);
42
42
  const deny = (opts.deny ?? []).map(compileCidrMatcher);
43
- const resolveIp = opts.resolveIp ??
44
- (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
43
+ const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
45
44
  const message = opts.message ?? "IP address not permitted";
46
45
  return {
47
46
  beforeHandle(ctx) {
@@ -200,11 +199,7 @@ function parseIPv6(input) {
200
199
  return undefined;
201
200
  const hi = (v4.bytes[0] << 8) | v4.bytes[1];
202
201
  const lo = (v4.bytes[2] << 8) | v4.bytes[3];
203
- working =
204
- working.slice(0, lastColon + 1) +
205
- hi.toString(16) +
206
- ":" +
207
- lo.toString(16);
202
+ working = working.slice(0, lastColon + 1) + hi.toString(16) + ":" + lo.toString(16);
208
203
  }
209
204
  const parts = working.split("::");
210
205
  if (parts.length > 2)
@@ -217,11 +212,7 @@ function parseIPv6(input) {
217
212
  if (parts.length === 1 && explicit !== 8)
218
213
  return undefined;
219
214
  const missing = parts.length === 2 ? 8 - explicit : 0;
220
- const groups = [
221
- ...headGroups,
222
- ...Array.from({ length: missing }, () => "0"),
223
- ...tailGroups,
224
- ];
215
+ const groups = [...headGroups, ...Array.from({ length: missing }, () => "0"), ...tailGroups];
225
216
  if (groups.length !== 8)
226
217
  return undefined;
227
218
  const bytes = new Uint8Array(16);