@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 +14 -3
- package/dist/app.js +39 -15
- package/dist/compression.d.ts +2 -0
- package/dist/compression.js +4 -52
- package/dist/conn-info.d.ts +3 -2
- package/dist/conn-info.js +11 -9
- package/dist/fetch-guard.d.ts +10 -1
- package/dist/fetch-guard.js +24 -3
- package/dist/fetch-resilience.d.ts +4 -0
- package/dist/fetch-resilience.js +10 -7
- package/dist/http-signatures.d.ts +12 -3
- package/dist/http-signatures.js +30 -11
- package/dist/idempotency.d.ts +16 -1
- package/dist/idempotency.js +28 -5
- package/dist/internal-body.d.ts +12 -0
- package/dist/internal-body.js +45 -0
- package/dist/internal-replay.d.ts +14 -0
- package/dist/internal-replay.js +21 -0
- package/dist/jwk.d.ts +6 -2
- package/dist/jwk.js +4 -0
- package/dist/jwt.d.ts +11 -1
- package/dist/jwt.js +21 -2
- package/dist/logger.d.ts +3 -1
- package/dist/logger.js +8 -0
- package/dist/mtls.d.ts +11 -3
- package/dist/mtls.js +59 -8
- package/dist/response-cache.d.ts +4 -0
- package/dist/response-cache.js +10 -2
- package/dist/router.d.ts +2 -1
- package/dist/router.js +2 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +31 -14
- package/dist/waf.js +19 -1
- package/dist/webhook-delivery.d.ts +2 -0
- package/dist/webhook-delivery.js +3 -0
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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
|
}
|
package/dist/compression.d.ts
CHANGED
|
@@ -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).
|
package/dist/compression.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/conn-info.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
286
|
-
if (
|
|
287
|
-
return
|
|
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
|
-
|
|
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
|
package/dist/fetch-guard.d.ts
CHANGED
|
@@ -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)
|
|
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;
|
package/dist/fetch-guard.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
315
|
+
throw new SsrfBlockedError("[invalid URL]", "invalid-url");
|
|
307
316
|
}
|
|
308
|
-
if (pre
|
|
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
|
*
|
package/dist/fetch-resilience.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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}.
|