@daloyjs/core 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.d.ts CHANGED
@@ -1049,7 +1049,7 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1049
1049
  */
1050
1050
  private responseBodySchemaAuditDone;
1051
1051
  /**
1052
- * Cached merge of `options.hooks` only. Used on the cold 404/405 path
1052
+ * Cached merge of `options.hooks` and secure-header response hooks. Used on the cold 404/405 path
1053
1053
  * and as the baseline for cross-origin guard decisions when no route
1054
1054
  * matches.
1055
1055
  */
@@ -1405,7 +1405,9 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1405
1405
  *
1406
1406
  * Defaults (secure-by-default):
1407
1407
  * - path: `/healthz`
1408
- * - rate-limit: 60 req/min per remote IP, in-memory (per-process)
1408
+ * - rate-limit: 60 req/min per trusted identity, or a shared global bucket,
1409
+ * in-memory (per-process). Verified-token and rejected requests use
1410
+ * separate budgets so unauthenticated traffic cannot exhaust probe capacity.
1409
1411
  * - auth: opt-in via `token`. In production with `secureDefaults: true`,
1410
1412
  * registration refuses to add the route without a `token` unless
1411
1413
  * `acknowledgeUnauthenticated: true` is set, so an unguarded
@@ -1416,6 +1418,9 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1416
1418
  * app.healthcheck({ token: process.env.HEALTH_TOKEN! });
1417
1419
  * ```
1418
1420
  *
1421
+ * @param opts - Probe path, token, and rate-limit policy.
1422
+ * @returns This app for chaining.
1423
+ * @throws {Error} If production authentication is missing without acknowledgment.
1419
1424
  * @since 0.18.0
1420
1425
  */
1421
1426
  healthcheck(opts?: HealthRouteOptions): this;
@@ -1428,6 +1433,9 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1428
1433
  *
1429
1434
  * Defaults match {@link App.healthcheck} (path defaults to `/readyz`).
1430
1435
  *
1436
+ * @param opts - Probe path, token, and rate-limit policy.
1437
+ * @returns This app for chaining.
1438
+ * @throws {Error} If production authentication is missing without acknowledgment.
1431
1439
  * @since 0.18.0
1432
1440
  */
1433
1441
  readinesscheck(opts?: HealthRouteOptions): this;
@@ -1445,7 +1453,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1445
1453
  *
1446
1454
  * The scrape route inherits the same hardened posture as
1447
1455
  * {@link App.healthcheck}: optional bearer token compared via
1448
- * {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
1456
+ * {@link timingSafeEqual}, a per-trusted-identity (otherwise global) fixed-window
1457
+ * rate limit with separate verified-token and rejected-request budgets, and a
1449
1458
  * refuse-to-boot guard in production (an unauthenticated `/metrics`
1450
1459
  * endpoint leaks internal route names, latency, and traffic volume) unless
1451
1460
  * a token is supplied or `acknowledgeUnauthenticated: true` is passed.
@@ -1462,6 +1471,7 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1462
1471
  *
1463
1472
  * @param opts - Path, auth, rate-limit, registry, and label configuration.
1464
1473
  * @returns `this` for chaining.
1474
+ * @throws {Error} If production authentication is missing without acknowledgment.
1465
1475
  * @since 0.37.0
1466
1476
  */
1467
1477
  metrics(opts?: MetricsRouteOptions): this;
@@ -1674,6 +1684,7 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1674
1684
  * ```
1675
1685
  *
1676
1686
  * @param hooks - Hook bundle applied to subsequent routes.
1687
+ * Secure-header hooks also apply to unmatched routes and early rejections.
1677
1688
  * @returns This `App` instance for chaining.
1678
1689
  */
1679
1690
  use(hooks: Hooks): this;
package/dist/app.js CHANGED
@@ -479,7 +479,7 @@ export class App {
479
479
  */
480
480
  responseBodySchemaAuditDone = false;
481
481
  /**
482
- * Cached merge of `options.hooks` only. Used on the cold 404/405 path
482
+ * Cached merge of `options.hooks` and secure-header response hooks. Used on the cold 404/405 path
483
483
  * and as the baseline for cross-origin guard decisions when no route
484
484
  * matches.
485
485
  */
@@ -487,7 +487,12 @@ export class App {
487
487
  _globalCorsAllowsCache;
488
488
  get globalHooks() {
489
489
  if (this._globalHooksCache === undefined) {
490
- this._globalHooksCache = mergeHooks([this.options.hooks ?? {}]);
490
+ this._globalHooksCache = mergeHooks([
491
+ this.options.hooks ?? {},
492
+ ...this.groupHooks
493
+ .filter(hook => hook[SECURE_HEADERS_MARKER] === true)
494
+ .map(hook => ({ onSend: hook.onSend, onResponse: hook.onResponse })),
495
+ ]);
491
496
  }
492
497
  return this._globalHooksCache;
493
498
  }
@@ -829,8 +834,13 @@ export class App {
829
834
  return;
830
835
  }
831
836
  const origin = request.headers.get("origin");
832
- if (!origin || origin === "null")
837
+ if (!origin)
833
838
  return;
839
+ if (origin === "null") {
840
+ if (corsOriginAllows.some((allows) => allows(origin)))
841
+ return;
842
+ throw new ForbiddenError("Cross-origin state-changing request rejected: opaque Origin requires an allowing cors() policy.");
843
+ }
834
844
  // Fast path: when both the Origin header and the request URL are in the
835
845
  // trivially-normalized shape (lowercase ASCII scheme://host[:port] with
836
846
  // no userinfo / percent-escapes / IPv6 brackets), their origins can be
@@ -1784,7 +1794,9 @@ export class App {
1784
1794
  *
1785
1795
  * Defaults (secure-by-default):
1786
1796
  * - path: `/healthz`
1787
- * - rate-limit: 60 req/min per remote IP, in-memory (per-process)
1797
+ * - rate-limit: 60 req/min per trusted identity, or a shared global bucket,
1798
+ * in-memory (per-process). Verified-token and rejected requests use
1799
+ * separate budgets so unauthenticated traffic cannot exhaust probe capacity.
1788
1800
  * - auth: opt-in via `token`. In production with `secureDefaults: true`,
1789
1801
  * registration refuses to add the route without a `token` unless
1790
1802
  * `acknowledgeUnauthenticated: true` is set, so an unguarded
@@ -1795,6 +1807,9 @@ export class App {
1795
1807
  * app.healthcheck({ token: process.env.HEALTH_TOKEN! });
1796
1808
  * ```
1797
1809
  *
1810
+ * @param opts - Probe path, token, and rate-limit policy.
1811
+ * @returns This app for chaining.
1812
+ * @throws {Error} If production authentication is missing without acknowledgment.
1798
1813
  * @since 0.18.0
1799
1814
  */
1800
1815
  healthcheck(opts = {}) {
@@ -1813,6 +1828,9 @@ export class App {
1813
1828
  *
1814
1829
  * Defaults match {@link App.healthcheck} (path defaults to `/readyz`).
1815
1830
  *
1831
+ * @param opts - Probe path, token, and rate-limit policy.
1832
+ * @returns This app for chaining.
1833
+ * @throws {Error} If production authentication is missing without acknowledgment.
1816
1834
  * @since 0.18.0
1817
1835
  */
1818
1836
  readinesscheck(opts = {}) {
@@ -1847,7 +1865,8 @@ export class App {
1847
1865
  *
1848
1866
  * The scrape route inherits the same hardened posture as
1849
1867
  * {@link App.healthcheck}: optional bearer token compared via
1850
- * {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
1868
+ * {@link timingSafeEqual}, a per-trusted-identity (otherwise global) fixed-window
1869
+ * rate limit with separate verified-token and rejected-request budgets, and a
1851
1870
  * refuse-to-boot guard in production (an unauthenticated `/metrics`
1852
1871
  * endpoint leaks internal route names, latency, and traffic volume) unless
1853
1872
  * a token is supplied or `acknowledgeUnauthenticated: true` is passed.
@@ -1864,6 +1883,7 @@ export class App {
1864
1883
  *
1865
1884
  * @param opts - Path, auth, rate-limit, registry, and label configuration.
1866
1885
  * @returns `this` for chaining.
1886
+ * @throws {Error} If production authentication is missing without acknowledgment.
1867
1887
  * @since 0.37.0
1868
1888
  */
1869
1889
  metrics(opts = {}) {
@@ -1931,8 +1951,11 @@ export class App {
1931
1951
  // Prometheus text exposition rendered by the framework registry.
1932
1952
  acknowledgeNoResponseBodySchema: true,
1933
1953
  handler: async ({ request }) => {
1954
+ const authMatch = token === undefined ? null : /^Bearer\s+(.+)$/i.exec(request.headers.get("authorization") ?? "");
1955
+ const tokenAccepted = token !== undefined && authMatch !== null && timingSafeEqual(authMatch[1], token);
1934
1956
  if (buckets && rateLimitConfig) {
1935
- const key = healthRouteKey(request, trustProxyHeaders);
1957
+ const identity = healthRouteKey(request, trustProxyHeaders);
1958
+ const key = token === undefined ? identity : `${tokenAccepted ? "verified" : "unverified"}\u0000${identity}`;
1936
1959
  const now = Date.now();
1937
1960
  const entry = buckets.get(key);
1938
1961
  if (!entry || entry.resetMs <= now) {
@@ -1949,16 +1972,14 @@ export class App {
1949
1972
  }
1950
1973
  }
1951
1974
  if (token !== undefined) {
1952
- const h = request.headers.get("authorization") ?? "";
1953
- const m = /^Bearer\s+(.+)$/i.exec(h);
1954
- if (!m) {
1975
+ if (!authMatch) {
1955
1976
  throw new HttpError(401, {
1956
1977
  type: "https://daloyjs.dev/errors/unauthorized",
1957
1978
  title: "Unauthorized",
1958
1979
  detail: "Metrics scrape requires a bearer token.",
1959
1980
  }, { "www-authenticate": 'Bearer realm="metrics"' });
1960
1981
  }
1961
- if (!timingSafeEqual(m[1], token)) {
1982
+ if (!tokenAccepted) {
1962
1983
  throw new ForbiddenError("Invalid metrics scrape token.");
1963
1984
  }
1964
1985
  }
@@ -2222,8 +2243,11 @@ export class App {
2222
2243
  // Framework-serialized probe payload; missing schema is intentional.
2223
2244
  acknowledgeNoResponseBodySchema: true,
2224
2245
  handler: async ({ request }) => {
2246
+ const authMatch = token === undefined ? null : /^Bearer\s+(.+)$/i.exec(request.headers.get("authorization") ?? "");
2247
+ const tokenAccepted = token !== undefined && authMatch !== null && timingSafeEqual(authMatch[1], token);
2225
2248
  if (buckets && rateLimitConfig) {
2226
- const key = healthRouteKey(request, trustProxyHeaders);
2249
+ const identity = healthRouteKey(request, trustProxyHeaders);
2250
+ const key = token === undefined ? identity : `${tokenAccepted ? "verified" : "unverified"}\u0000${identity}`;
2227
2251
  const now = Date.now();
2228
2252
  const entry = buckets.get(key);
2229
2253
  if (!entry || entry.resetMs <= now) {
@@ -2240,16 +2264,14 @@ export class App {
2240
2264
  }
2241
2265
  }
2242
2266
  if (token !== undefined) {
2243
- const h = request.headers.get("authorization") ?? "";
2244
- const m = /^Bearer\s+(.+)$/i.exec(h);
2245
- if (!m) {
2267
+ if (!authMatch) {
2246
2268
  throw new HttpError(401, {
2247
2269
  type: "https://daloyjs.dev/errors/unauthorized",
2248
2270
  title: "Unauthorized",
2249
2271
  detail: "Health probe requires a bearer token.",
2250
2272
  }, { "www-authenticate": 'Bearer realm="health"' });
2251
2273
  }
2252
- if (!timingSafeEqual(m[1], token)) {
2274
+ if (!tokenAccepted) {
2253
2275
  throw new ForbiddenError("Invalid health probe token.");
2254
2276
  }
2255
2277
  }
@@ -2459,6 +2481,7 @@ export class App {
2459
2481
  * ```
2460
2482
  *
2461
2483
  * @param hooks - Hook bundle applied to subsequent routes.
2484
+ * Secure-header hooks also apply to unmatched routes and early rejections.
2462
2485
  * @returns This `App` instance for chaining.
2463
2486
  */
2464
2487
  use(hooks) {
@@ -2480,6 +2503,7 @@ export class App {
2480
2503
  }
2481
2504
  this.groupHooks.push(hooks);
2482
2505
  this._coldPathHooksCache = undefined;
2506
+ this._globalHooksCache = undefined;
2483
2507
  if (hooks[CORS_HOOK_MARKER] === true) {
2484
2508
  this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
2485
2509
  }
@@ -115,6 +115,8 @@ export declare function _resetCompressionRuntimeProbeForTests(): void;
115
115
  * - response already declares a `Content-Encoding`;
116
116
  * - response declares a `Set-Cookie` (response is mutating auth state);
117
117
  * - response body byte length is below `minimumSize` (default `1024`);
118
+ * - response body exceeds `maxCompressibleBytes` while reading; only the
119
+ * capture clone is cancelled, without waiting for the client to consume it;
118
120
  * - response `Content-Type` is in the always-on already-compressed
119
121
  * deny-list (image/video/audio/archives/fonts/wasm/pdf, with
120
122
  * `image/svg+xml` carved back in as compressible XML).
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * @since 0.25.0
22
22
  */
23
+ import { readResponseBodyUpTo } from "./internal-body.js";
23
24
  /**
24
25
  * Internal marker stamped on the hook so a future audit gate can confirm
25
26
  * the BREACH-aware middleware is installed (audit-item parity).
@@ -217,57 +218,6 @@ function normalizeOptionTokens(values, optionName) {
217
218
  }
218
219
  return Object.freeze(normalized);
219
220
  }
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
- }
271
221
  async function compressBytes(bytes, encoding) {
272
222
  const Stream = globalThis.CompressionStream;
273
223
  const cs = new Stream(encoding);
@@ -317,6 +267,8 @@ async function compressBytes(bytes, encoding) {
317
267
  * - response already declares a `Content-Encoding`;
318
268
  * - response declares a `Set-Cookie` (response is mutating auth state);
319
269
  * - response body byte length is below `minimumSize` (default `1024`);
270
+ * - response body exceeds `maxCompressibleBytes` while reading; only the
271
+ * capture clone is cancelled, without waiting for the client to consume it;
320
272
  * - response `Content-Type` is in the always-on already-compressed
321
273
  * deny-list (image/video/audio/archives/fonts/wasm/pdf, with
322
274
  * `image/svg+xml` carved back in as compressible XML).
@@ -407,7 +359,7 @@ export function compression(opts = {}) {
407
359
  if (Number.isFinite(n) && n > maxCompressibleBytes)
408
360
  return undefined;
409
361
  }
410
- const original = await readBodyUpTo(res.clone(), maxCompressibleBytes);
362
+ const original = await readResponseBodyUpTo(res.clone(), maxCompressibleBytes).catch(() => null);
411
363
  if (original === null)
412
364
  return undefined; // exceeded cap while streaming
413
365
  if (original.byteLength < minimumSize)
@@ -108,7 +108,7 @@ export declare function assertBehindProxy(cfg: BehindProxyConfig | undefined): v
108
108
  * @param header - Raw `X-Forwarded-For` header value, or `null` when absent.
109
109
  * @param hops - Declared number of trusted proxy hops (must be >= 1).
110
110
  * @returns The client IP at the declared hop, or `undefined` when the chain
111
- * is too short or `hops < 1`.
111
+ * is too short, the selected slot is not an IP, or `hops < 1`.
112
112
  * @internal
113
113
  */
114
114
  export declare function pickForwardedForByHops(header: string | null, hops: number): string | undefined;
@@ -216,7 +216,8 @@ export declare function resolveTrustedProxyMatchers(name: string, opts: {
216
216
  * {@link resolveTrustedProxyMatchers}. When supplied, forwarded headers
217
217
  * are honoured only if the immediate peer matches; otherwise `undefined`.
218
218
  * @returns The resolved client IP, or `undefined` when no forwarded identity
219
- * is available. Callers decide their own posture for `undefined`
219
+ * is available. Placeholder and malformed identities are rejected without
220
+ * shifting the selected hop. Callers decide their own posture for `undefined`
220
221
  * (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
221
222
  * @since 1.0.0-rc.7
222
223
  */
package/dist/conn-info.js CHANGED
@@ -101,7 +101,7 @@ export function assertBehindProxy(cfg) {
101
101
  * @param header - Raw `X-Forwarded-For` header value, or `null` when absent.
102
102
  * @param hops - Declared number of trusted proxy hops (must be >= 1).
103
103
  * @returns The client IP at the declared hop, or `undefined` when the chain
104
- * is too short or `hops < 1`.
104
+ * is too short, the selected slot is not an IP, or `hops < 1`.
105
105
  * @internal
106
106
  */
107
107
  export function pickForwardedForByHops(header, hops) {
@@ -109,13 +109,13 @@ export function pickForwardedForByHops(header, hops) {
109
109
  return undefined;
110
110
  const parts = header
111
111
  .split(",")
112
- .map((p) => p.trim())
113
- .filter((p) => p.length > 0);
112
+ .map((p) => p.trim());
114
113
  if (parts.length < hops)
115
114
  return undefined;
116
115
  // Right-to-left: index 0 is the last hop closest to Daloy. The client
117
116
  // typically lives at parts[parts.length - hops].
118
- return parts[parts.length - hops];
117
+ const selected = parts[parts.length - hops];
118
+ return selected && parseIp(selected) ? selected : undefined;
119
119
  }
120
120
  /**
121
121
  * Validate a middleware's forwarded-header trust options and resolve them into
@@ -274,7 +274,8 @@ function isTrustedPeer(request, trustedPeers) {
274
274
  * {@link resolveTrustedProxyMatchers}. When supplied, forwarded headers
275
275
  * are honoured only if the immediate peer matches; otherwise `undefined`.
276
276
  * @returns The resolved client IP, or `undefined` when no forwarded identity
277
- * is available. Callers decide their own posture for `undefined`
277
+ * is available. Placeholder and malformed identities are rejected without
278
+ * shifting the selected hop. Callers decide their own posture for `undefined`
278
279
  * (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
279
280
  * @since 1.0.0-rc.7
280
281
  */
@@ -282,9 +283,9 @@ export function resolveForwardedClientIp(request, hops = 1, trustedPeers) {
282
283
  if (trustedPeers !== undefined && !isTrustedPeer(request, trustedPeers)) {
283
284
  return undefined;
284
285
  }
285
- const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), hops);
286
- if (picked)
287
- return picked;
286
+ const forwarded = request.headers.get("x-forwarded-for");
287
+ if (forwarded !== null)
288
+ return pickForwardedForByHops(forwarded, hops);
288
289
  // Fail closed past one hop. A chain that produced fewer than `hops` entries
289
290
  // means the request never traversed the declared topology — a direct-to-origin
290
291
  // request that skipped the CDN, say — so no forwarded value it carries is
@@ -298,7 +299,8 @@ export function resolveForwardedClientIp(request, hops = 1, trustedPeers) {
298
299
  // identity", not "the identity the caller asked me to believe".
299
300
  if (hops !== 1)
300
301
  return undefined;
301
- return request.headers.get("x-real-ip") ?? undefined;
302
+ const realIp = request.headers.get("x-real-ip")?.trim();
303
+ return realIp && parseIp(realIp) ? realIp : undefined;
302
304
  }
303
305
  /**
304
306
  * Compiled-CIDR cache for {@link resolveClientIp}'s `{ cidrs }` branch. Keyed
@@ -254,7 +254,16 @@ export interface FetchGuardOptions {
254
254
  * @param options - Guard configuration; see {@link FetchGuardOptions}. Omit
255
255
  * for the strict default posture (public IPs over `http:`/`https:` only).
256
256
  * @returns A `fetch`-compatible function that validates every hop (including
257
- * redirects) and throws {@link SsrfBlockedError} on refusal.
257
+ * redirects). Cross-origin redirects remove `Authorization`, `Cookie`,
258
+ * `Proxy-Authorization`, and explicit `Host` headers; same-origin redirects
259
+ * preserve them. Custom credential headers must not be used with untrusted
260
+ * redirect destinations; use `redirect: "error"` or `"manual"` in that case.
261
+ * @throws {Error} If no underlying fetch implementation is available.
262
+ * @throws {SsrfBlockedError} The returned function throws when a destination
263
+ * or redirect chain violates policy, including malformed URLs and userinfo.
264
+ * Userinfo is removed from error URLs; network errors propagate.
265
+ * @throws {TypeError} The returned function throws on invalid requests or
266
+ * redirects when `redirect: "error"` is selected.
258
267
  * @since 0.34.0
259
268
  */
260
269
  export declare function fetchGuard(options?: FetchGuardOptions): typeof fetch;
@@ -176,7 +176,16 @@ const UNIQUE_LOCAL = ["fc00::/7"];
176
176
  * @param options - Guard configuration; see {@link FetchGuardOptions}. Omit
177
177
  * for the strict default posture (public IPs over `http:`/`https:` only).
178
178
  * @returns A `fetch`-compatible function that validates every hop (including
179
- * redirects) and throws {@link SsrfBlockedError} on refusal.
179
+ * redirects). Cross-origin redirects remove `Authorization`, `Cookie`,
180
+ * `Proxy-Authorization`, and explicit `Host` headers; same-origin redirects
181
+ * preserve them. Custom credential headers must not be used with untrusted
182
+ * redirect destinations; use `redirect: "error"` or `"manual"` in that case.
183
+ * @throws {Error} If no underlying fetch implementation is available.
184
+ * @throws {SsrfBlockedError} The returned function throws when a destination
185
+ * or redirect chain violates policy, including malformed URLs and userinfo.
186
+ * Userinfo is removed from error URLs; network errors propagate.
187
+ * @throws {TypeError} The returned function throws on invalid requests or
188
+ * redirects when `redirect: "error"` is selected.
180
189
  * @since 0.34.0
181
190
  */
182
191
  export function fetchGuard(options = {}) {
@@ -303,9 +312,9 @@ export function fetchGuard(options = {}) {
303
312
  pre = new URL(input);
304
313
  }
305
314
  catch {
306
- pre = undefined;
315
+ throw new SsrfBlockedError("[invalid URL]", "invalid-url");
307
316
  }
308
- if (pre && (pre.username !== "" || pre.password !== "")) {
317
+ if (pre.username !== "" || pre.password !== "") {
309
318
  pre.username = "";
310
319
  pre.password = "";
311
320
  throw new SsrfBlockedError(pre.toString(), "credentials-in-url");
@@ -354,6 +363,12 @@ export function fetchGuard(options = {}) {
354
363
  catch {
355
364
  throw new SsrfBlockedError(loc, "invalid-url");
356
365
  }
366
+ if (next.username !== "" || next.password !== "") {
367
+ next.username = "";
368
+ next.password = "";
369
+ void res.body?.cancel().catch(() => undefined);
370
+ throw new SsrfBlockedError(next.toString(), "credentials-in-url");
371
+ }
357
372
  // Per fetch spec: 303 (and 301/302 for non-GET/HEAD in practice) downgrade to GET.
358
373
  const method = request.method.toUpperCase();
359
374
  const shouldDowngrade = res.status === 303 ||
@@ -371,6 +386,12 @@ export function fetchGuard(options = {}) {
371
386
  referrerPolicy: request.referrerPolicy,
372
387
  })
373
388
  : new Request(next, request);
389
+ if (next.origin !== currentUrl.origin) {
390
+ request.headers.delete("authorization");
391
+ request.headers.delete("cookie");
392
+ request.headers.delete("proxy-authorization");
393
+ request.headers.delete("host");
394
+ }
374
395
  currentUrl = next;
375
396
  }
376
397
  };
@@ -283,6 +283,10 @@ export interface ResilientFetchOptions {
283
283
  * Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
284
284
  * circuit breaker. The returned function has the same call signature as
285
285
  * the global `fetch`.
286
+ * Response bodies discarded for a retry are cancelled before backoff without
287
+ * waiting for producer cancellation. The final response remains caller-owned.
288
+ * Caller cancellation preserves arbitrary abort reasons and never counts as
289
+ * an upstream failure or schedules another retry.
286
290
  *
287
291
  * Layer it over {@link fetchGuard} to keep SSRF protection underneath:
288
292
  *
@@ -348,6 +348,10 @@ function isAbortError(err) {
348
348
  * Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
349
349
  * circuit breaker. The returned function has the same call signature as
350
350
  * the global `fetch`.
351
+ * Response bodies discarded for a retry are cancelled before backoff without
352
+ * waiting for producer cancellation. The final response remains caller-owned.
353
+ * Caller cancellation preserves arbitrary abort reasons and never counts as
354
+ * an upstream failure or schedules another retry.
351
355
  *
352
356
  * Layer it over {@link fetchGuard} to keep SSRF protection underneath:
353
357
  *
@@ -416,6 +420,7 @@ export function resilientFetch(options = {}) {
416
420
  // the caller's signal can be combined per attempt.
417
421
  const request = new Request(input, init);
418
422
  const callerSignal = init?.signal ?? request.signal;
423
+ callerSignal?.throwIfAborted();
419
424
  const run = async () => {
420
425
  let lastError;
421
426
  for (let attempt = 1; attempt <= retries + 1; attempt++) {
@@ -428,8 +433,7 @@ export function resilientFetch(options = {}) {
428
433
  catch (err) {
429
434
  cleanup();
430
435
  // Caller cancelled: never retry, never count as upstream failure.
431
- if (isAbortError(err) && callerSignal?.aborted)
432
- throw err;
436
+ callerSignal?.throwIfAborted();
433
437
  // An SSRF refusal from an underlying fetchGuard is a hard, terminal
434
438
  // decision about the request itself — never retried.
435
439
  if (err instanceof Error && err.name === "SsrfBlockedError")
@@ -441,8 +445,7 @@ export function resilientFetch(options = {}) {
441
445
  const delay = backoffFor(attempt);
442
446
  options.onRetry?.(ctx, delay);
443
447
  await sleep(delay, callerSignal ?? undefined);
444
- if (callerSignal?.aborted)
445
- throw lastError;
448
+ callerSignal?.throwIfAborted();
446
449
  continue;
447
450
  }
448
451
  throw lastError;
@@ -452,9 +455,9 @@ export function resilientFetch(options = {}) {
452
455
  if (attempt <= retries && shouldRetry(ctx)) {
453
456
  const delay = backoffFor(attempt, response);
454
457
  options.onRetry?.(ctx, delay);
458
+ void response.body?.cancel().catch(() => undefined);
455
459
  await sleep(delay, callerSignal ?? undefined);
456
- if (callerSignal?.aborted)
457
- return response;
460
+ callerSignal?.throwIfAborted();
458
461
  continue;
459
462
  }
460
463
  return response;
@@ -476,7 +479,7 @@ export function resilientFetch(options = {}) {
476
479
  // SSRF refusals and caller aborts are not upstream health signals.
477
480
  if (err instanceof CircuitOpenError)
478
481
  throw err;
479
- const isCallerAbort = isAbortError(err) && callerSignal?.aborted;
482
+ const isCallerAbort = callerSignal?.aborted;
480
483
  const isSsrf = err instanceof Error && err.name === "SsrfBlockedError";
481
484
  if (isCallerAbort || isSsrf)
482
485
  breaker.release();
@@ -39,7 +39,11 @@ import type { Hooks } from "./types.js";
39
39
  * @since 0.37.0
40
40
  */
41
41
  export type HttpSignatureAlgorithm = "hmac-sha256" | "ed25519" | "ecdsa-p256-sha256" | "ecdsa-p384-sha384" | "rsa-pss-sha512" | "rsa-v1_5-sha256";
42
- /** Key material accepted by the signer/verifier. */
42
+ /**
43
+ * Key material accepted by the signer/verifier. Imported keys must match the
44
+ * selected algorithm's family, hash and curve, with the same 32-byte HMAC and
45
+ * 2048-bit RSA minimums as raw keys.
46
+ */
43
47
  export type HttpSignatureKeyMaterial = CryptoKey | Uint8Array | JsonWebKey;
44
48
  /**
45
49
  * A resolved verification key, optionally pinning the algorithm it may be used
@@ -134,7 +138,8 @@ export interface MessageSignature {
134
138
  * @returns The `Signature-Input` / `Signature` header values plus the exact
135
139
  * signature base that was signed.
136
140
  * @throws {TypeError} for unsupported algorithms, weak HMAC keys, or
137
- * unserializable parameter values.
141
+ * unserializable parameter values. Imported keys must match the selected
142
+ * algorithm's family, hash and curve and meet its strength floor.
138
143
  * @throws {Error} when a covered component cannot be resolved (e.g. a covered
139
144
  * header is missing) or WebCrypto is unavailable.
140
145
  * @since 0.37.0
@@ -245,7 +250,8 @@ export interface VerifyMessageOptions {
245
250
  requiredTag?: string;
246
251
  /**
247
252
  * Replay check. When provided, a `nonce` is required and the signature is
248
- * rejected if this returns `true`.
253
+ * rejected if this returns `true`. Called only after cryptographic verification
254
+ * succeeds; implementations that record nonces must check and record atomically.
249
255
  */
250
256
  isReplay?: (nonce: string, info: KeyResolutionInfo) => boolean | Promise<boolean>;
251
257
  /** Clock used for age checks. Returns milliseconds. Defaults to `Date.now`. */
@@ -255,6 +261,7 @@ export interface VerifyMessageOptions {
255
261
  * Verify an HTTP Message Signature (RFC 9421) on a received message. Returns a
256
262
  * structured result and never throws on a bad/forged signature — only on a
257
263
  * programming error (e.g. WebCrypto unavailable).
264
+ * Imported keys that violate the algorithm or strength policy return invalid_key.
258
265
  *
259
266
  * @param opts - Received message plus verification policy (algorithm
260
267
  * allowlist, key resolver, freshness / replay checks); see
@@ -300,6 +307,8 @@ export interface HttpSignatureAuthOptions extends Omit<VerifyMessageOptions, "me
300
307
  * requests. On success the {@link VerifySuccess} is stamped on `ctx.state`; on
301
308
  * a missing (unless `optional`) or invalid signature it throws
302
309
  * {@link UnauthorizedError} (`401` + `Cache-Control: no-store`).
310
+ * Verification runs before body I/O and before stored-response middleware,
311
+ * so cache hits and idempotency replays cannot skip signature authentication.
303
312
  *
304
313
  * @param opts - Verification policy plus middleware knobs; see
305
314
  * {@link HttpSignatureAuthOptions}.