@daloyjs/core 1.0.0-rc.5 → 1.0.0-rc.7

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 (51) hide show
  1. package/README.md +25 -14
  2. package/dist/adapters/bun.js +1 -2
  3. package/dist/adapters/node.js +16 -30
  4. package/dist/app.d.ts +5 -1
  5. package/dist/app.js +74 -6
  6. package/dist/auto-ban.d.ts +16 -0
  7. package/dist/auto-ban.js +20 -12
  8. package/dist/bot-guard.d.ts +14 -0
  9. package/dist/bot-guard.js +12 -12
  10. package/dist/cli.js +9 -6
  11. package/dist/concurrency-limit.d.ts +14 -0
  12. package/dist/concurrency-limit.js +18 -9
  13. package/dist/config.js +1 -3
  14. package/dist/conn-info.d.ts +65 -0
  15. package/dist/conn-info.js +99 -4
  16. package/dist/errors.js +2 -5
  17. package/dist/etag.js +12 -2
  18. package/dist/geo-block.d.ts +15 -0
  19. package/dist/geo-block.js +14 -19
  20. package/dist/hashing.js +1 -1
  21. package/dist/http-signatures.js +3 -8
  22. package/dist/index.d.ts +5 -5
  23. package/dist/index.js +4 -4
  24. package/dist/ip-reputation.d.ts +14 -0
  25. package/dist/ip-reputation.js +11 -11
  26. package/dist/ip-restriction.d.ts +14 -0
  27. package/dist/ip-restriction.js +7 -18
  28. package/dist/jwt.js +12 -14
  29. package/dist/logger.js +1 -3
  30. package/dist/mcp.d.ts +305 -34
  31. package/dist/mcp.js +554 -49
  32. package/dist/middleware.d.ts +31 -1
  33. package/dist/middleware.js +21 -19
  34. package/dist/multipart.js +9 -12
  35. package/dist/openapi.d.ts +1 -1
  36. package/dist/openapi.js +2 -2
  37. package/dist/rate-limit-redis.d.ts +4 -4
  38. package/dist/response-cache.d.ts +179 -21
  39. package/dist/response-cache.js +338 -29
  40. package/dist/safe-redirect.js +3 -1
  41. package/dist/sbom.cdx.json +9 -9
  42. package/dist/sbom.spdx.json +5 -5
  43. package/dist/security-schemes.js +1 -2
  44. package/dist/subdomains.js +1 -4
  45. package/dist/tenancy.d.ts +40 -0
  46. package/dist/tenancy.js +54 -3
  47. package/dist/waf.js +40 -8
  48. package/dist/webhook-delivery.js +19 -3
  49. package/dist/websocket.d.ts +8 -0
  50. package/dist/websocket.js +19 -4
  51. package/package.json +2 -2
@@ -46,6 +46,7 @@
46
46
  * @since 0.37.0
47
47
  */
48
48
  import { HttpError } from "./errors.js";
49
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
49
50
  const DEFAULT_MESSAGE = "Concurrency limit exceeded";
50
51
  /** Monotonic id so multiple mounted limiters use distinct per-request state slots. */
51
52
  let instanceCounter = 0;
@@ -59,12 +60,13 @@ function assertNonNegativeInteger(name, value) {
59
60
  throw new Error(`concurrencyLimit(): ${name} must be a non-negative integer.`);
60
61
  }
61
62
  }
62
- function forwardedKey(ctx) {
63
- const forwarded = ctx.request.headers.get("x-forwarded-for");
64
- const first = forwarded ? forwarded.split(",")[0].trim() : "";
65
- if (first)
66
- return first;
67
- return ctx.request.headers.get("x-real-ip") ?? undefined;
63
+ /**
64
+ * @internal Default identity resolver: the client IP `hops` entries from the
65
+ * right of `X-Forwarded-For` (falling back to `X-Real-IP`) the
66
+ * spoof-resistant side of the header; see {@link resolveForwardedClientIp}.
67
+ */
68
+ function forwardedKey(hops) {
69
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
68
70
  }
69
71
  /** Extract just the pathname from a request URL without a full `URL` parse where possible. */
70
72
  function pathnameOf(url) {
@@ -104,11 +106,18 @@ function buildScopeResolver(opts) {
104
106
  return (ctx) => `${ctx.request.method} ${pathnameOf(ctx.request.url)}`;
105
107
  }
106
108
  // scope === "client"
107
- if (!opts.keyGenerator && !opts.trustProxyHeaders) {
108
- throw new Error('concurrencyLimit(): scope "client" requires keyGenerator or trustProxyHeaders so ' +
109
+ const hops = resolveForwardedTrust("concurrencyLimit()", opts);
110
+ let resolve;
111
+ if (opts.keyGenerator) {
112
+ resolve = opts.keyGenerator;
113
+ }
114
+ else if (hops !== undefined) {
115
+ resolve = forwardedKey(hops);
116
+ }
117
+ else {
118
+ throw new Error('concurrencyLimit(): scope "client" requires keyGenerator, trustedHops, or trustProxyHeaders so ' +
109
119
  "clients can be identified; otherwise every caller shares one bucket.");
110
120
  }
111
- const resolve = opts.keyGenerator ?? forwardedKey;
112
121
  return (ctx) => {
113
122
  const id = resolve(ctx);
114
123
  return id === undefined ? undefined : `client:${id}`;
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;
@@ -111,6 +111,71 @@ export declare function assertBehindProxy(cfg: BehindProxyConfig | undefined): v
111
111
  * @internal
112
112
  */
113
113
  export declare function pickForwardedForByHops(header: string | null, hops: number): string | undefined;
114
+ /**
115
+ * Validate a middleware's forwarded-header trust options and resolve them into
116
+ * a single hop count. Every middleware that keys on client IP calls this once
117
+ * at construction, so the trust policy lives in exactly one place instead of
118
+ * being re-derived at each call site.
119
+ *
120
+ * That single-source property is the point: the spoofable-IP vulnerability this
121
+ * module now guards against existed in nine independent copies of the same
122
+ * leftmost-`X-Forwarded-For` read, which meant nine separate places to get it
123
+ * wrong and nine separate fixes. Keeping the decision here means a future
124
+ * change to the trust rules lands everywhere at once.
125
+ *
126
+ * `trustedHops` must be an integer in [1, 64], mirroring the
127
+ * `behindProxy.hops` range: the floor of one exists because a middleware that
128
+ * trusts zero proxy hops has no business reading forwarding headers at all.
129
+ *
130
+ * @param name - Middleware function name used in error messages.
131
+ * @param opts - The middleware's options object; only the two trust fields are
132
+ * read, so any middleware option type is structurally acceptable.
133
+ * @returns The number of trusted proxy hops when forwarded-header trust is
134
+ * enabled — `trustedHops` verbatim, or `1` for a bare
135
+ * `trustProxyHeaders: true` — or `undefined` when trust is off and the caller
136
+ * must not read forwarding headers at all.
137
+ * @throws Error when `trustedHops` is not an integer in [1, 64], or when
138
+ * `trustProxyHeaders: false` is combined with a `trustedHops` value. That
139
+ * pairing is a contradiction, and it previously resolved silently in favour
140
+ * of trust — meaning an explicit opt-out was ignored.
141
+ * @internal
142
+ */
143
+ export declare function resolveForwardedTrust(name: string, opts: {
144
+ trustedHops?: number;
145
+ trustProxyHeaders?: boolean;
146
+ }): number | undefined;
147
+ /**
148
+ * Resolve the client IP from the proxy-set forwarding headers, walking a
149
+ * declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
150
+ *
151
+ * The right side is the spoof-resistant side: each proxy in the chain appends
152
+ * the address of the peer it actually observed, so the last `hops` entries
153
+ * were written by infrastructure you control, while anything further left is
154
+ * attacker-influenceable. Reading the leftmost entry (the historic
155
+ * `split(",")[0]` pattern) trusts the MOST attacker-controllable slot and
156
+ * enabled both rate-limit/ban evasion (rotate a spoofed left entry) and
157
+ * victim-IP framing (spoof a victim's address to get them banned or blocked).
158
+ *
159
+ * Falls back to `X-Real-IP` **only for a single declared hop**. That header
160
+ * carries exactly one hop of information, so it can stand in for a one-proxy
161
+ * declaration (the common nginx `X-Real-IP`-only setup) but cannot possibly
162
+ * satisfy a two-or-more-hop one. With 2+ declared hops and a chain shorter
163
+ * than the declaration, this returns `undefined` rather than guessing.
164
+ *
165
+ * Security note: this resolver is only meaningful when every request reaches
166
+ * the app through a proxy chain you control that appends (or overwrites)
167
+ * these headers. With no proxy in front, any forwarded-header trust is
168
+ * attacker-controlled by definition.
169
+ *
170
+ * @param request - Incoming request whose forwarding headers are read.
171
+ * @param hops - Number of trusted proxy hops; `1` (default) reads the
172
+ * rightmost entry — the one your immediate proxy appended.
173
+ * @returns The resolved client IP, or `undefined` when no forwarded identity
174
+ * is available. Callers decide their own posture for `undefined`
175
+ * (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
176
+ * @since 1.0.0-rc.7
177
+ */
178
+ export declare function resolveForwardedClientIp(request: Request, hops?: number): string | undefined;
114
179
  /**
115
180
  * Resolve the client IP for this request using the configured
116
181
  * {@link BehindProxyConfig}. Returns `undefined` when no trusted source is
package/dist/conn-info.js CHANGED
@@ -106,6 +106,100 @@ export function pickForwardedForByHops(header, hops) {
106
106
  // typically lives at parts[parts.length - hops].
107
107
  return parts[parts.length - hops];
108
108
  }
109
+ /**
110
+ * Validate a middleware's forwarded-header trust options and resolve them into
111
+ * a single hop count. Every middleware that keys on client IP calls this once
112
+ * at construction, so the trust policy lives in exactly one place instead of
113
+ * being re-derived at each call site.
114
+ *
115
+ * That single-source property is the point: the spoofable-IP vulnerability this
116
+ * module now guards against existed in nine independent copies of the same
117
+ * leftmost-`X-Forwarded-For` read, which meant nine separate places to get it
118
+ * wrong and nine separate fixes. Keeping the decision here means a future
119
+ * change to the trust rules lands everywhere at once.
120
+ *
121
+ * `trustedHops` must be an integer in [1, 64], mirroring the
122
+ * `behindProxy.hops` range: the floor of one exists because a middleware that
123
+ * trusts zero proxy hops has no business reading forwarding headers at all.
124
+ *
125
+ * @param name - Middleware function name used in error messages.
126
+ * @param opts - The middleware's options object; only the two trust fields are
127
+ * read, so any middleware option type is structurally acceptable.
128
+ * @returns The number of trusted proxy hops when forwarded-header trust is
129
+ * enabled — `trustedHops` verbatim, or `1` for a bare
130
+ * `trustProxyHeaders: true` — or `undefined` when trust is off and the caller
131
+ * must not read forwarding headers at all.
132
+ * @throws Error when `trustedHops` is not an integer in [1, 64], or when
133
+ * `trustProxyHeaders: false` is combined with a `trustedHops` value. That
134
+ * pairing is a contradiction, and it previously resolved silently in favour
135
+ * of trust — meaning an explicit opt-out was ignored.
136
+ * @internal
137
+ */
138
+ export function resolveForwardedTrust(name, opts) {
139
+ const hops = opts.trustedHops;
140
+ const trust = opts.trustProxyHeaders;
141
+ if (hops !== undefined) {
142
+ if (!Number.isInteger(hops) || hops < 1 || hops > 64) {
143
+ throw new Error(`${name}: trustedHops must be an integer in [1, 64]; got ${String(hops)}.`);
144
+ }
145
+ if (trust === false) {
146
+ throw new Error(`${name}: trustProxyHeaders: false contradicts trustedHops: ${hops}. ` +
147
+ "trustedHops implies proxy-header trust; drop whichever one you did not mean.");
148
+ }
149
+ return hops;
150
+ }
151
+ return trust === true ? 1 : undefined;
152
+ }
153
+ /**
154
+ * Resolve the client IP from the proxy-set forwarding headers, walking a
155
+ * declared number of trusted hops from the RIGHT side of `X-Forwarded-For`.
156
+ *
157
+ * The right side is the spoof-resistant side: each proxy in the chain appends
158
+ * the address of the peer it actually observed, so the last `hops` entries
159
+ * were written by infrastructure you control, while anything further left is
160
+ * attacker-influenceable. Reading the leftmost entry (the historic
161
+ * `split(",")[0]` pattern) trusts the MOST attacker-controllable slot and
162
+ * enabled both rate-limit/ban evasion (rotate a spoofed left entry) and
163
+ * victim-IP framing (spoof a victim's address to get them banned or blocked).
164
+ *
165
+ * Falls back to `X-Real-IP` **only for a single declared hop**. That header
166
+ * carries exactly one hop of information, so it can stand in for a one-proxy
167
+ * declaration (the common nginx `X-Real-IP`-only setup) but cannot possibly
168
+ * satisfy a two-or-more-hop one. With 2+ declared hops and a chain shorter
169
+ * than the declaration, this returns `undefined` rather than guessing.
170
+ *
171
+ * Security note: this resolver is only meaningful when every request reaches
172
+ * the app through a proxy chain you control that appends (or overwrites)
173
+ * these headers. With no proxy in front, any forwarded-header trust is
174
+ * attacker-controlled by definition.
175
+ *
176
+ * @param request - Incoming request whose forwarding headers are read.
177
+ * @param hops - Number of trusted proxy hops; `1` (default) reads the
178
+ * rightmost entry — the one your immediate proxy appended.
179
+ * @returns The resolved client IP, or `undefined` when no forwarded identity
180
+ * is available. Callers decide their own posture for `undefined`
181
+ * (fail-closed 403, fail-open skip, or a shared `"global"` bucket).
182
+ * @since 1.0.0-rc.7
183
+ */
184
+ export function resolveForwardedClientIp(request, hops = 1) {
185
+ const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), hops);
186
+ if (picked)
187
+ return picked;
188
+ // Fail closed past one hop. A chain that produced fewer than `hops` entries
189
+ // means the request never traversed the declared topology — a direct-to-origin
190
+ // request that skipped the CDN, say — so no forwarded value it carries is
191
+ // trustworthy, `X-Real-IP` least of all. Trusting it here would hand back the
192
+ // rotating-identity evasion and victim-IP framing that reading from the right
193
+ // exists to prevent.
194
+ //
195
+ // An attacker inside the chain cannot reach this path: conforming proxies
196
+ // append, so prepending entries only ever lengthens the header. Reaching it
197
+ // requires bypassing the declared chain, and the safe answer there is "no
198
+ // identity", not "the identity the caller asked me to believe".
199
+ if (hops !== 1)
200
+ return undefined;
201
+ return request.headers.get("x-real-ip") ?? undefined;
202
+ }
109
203
  /**
110
204
  * Resolve the client IP for this request using the configured
111
205
  * {@link BehindProxyConfig}. Returns `undefined` when no trusted source is
@@ -125,10 +219,11 @@ export function resolveClientIp(request, cfg) {
125
219
  return peer;
126
220
  if (cfg === "loopback") {
127
221
  if (peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1") {
128
- const xff = request.headers.get("x-forwarded-for");
129
- const first = xff?.split(",")[0]?.trim();
130
- if (first)
131
- return first;
222
+ // The same-host proxy is the single trusted hop, so read the slot IT
223
+ // appended (rightmost), never the attacker-influenceable leftmost one.
224
+ const picked = pickForwardedForByHops(request.headers.get("x-forwarded-for"), 1);
225
+ if (picked)
226
+ return picked;
132
227
  }
133
228
  return peer;
134
229
  }
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
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);
@@ -116,8 +116,23 @@ export interface GeoBlockOptions {
116
116
  * to `false` because those headers are client-spoofable unless every
117
117
  * request reaches Daloy through a proxy chain you control. Ignored when
118
118
  * `resolveCountry` is used or a custom `resolveIp` is supplied.
119
+ *
120
+ * When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
121
+ * entry — the one your immediate proxy appended — never the
122
+ * attacker-influenceable leftmost one. Behind more than one proxy hop, set
123
+ * {@link trustedHops} instead.
119
124
  */
120
125
  trustProxyHeaders?: boolean;
126
+ /**
127
+ * Declare exactly how many proxy hops sit between Daloy and the public
128
+ * internet. Implies proxy-header trust and reads the client IP that many
129
+ * entries from the right of `X-Forwarded-For` via
130
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
131
+ * entries on the left cannot spoof an allowed-country IP. Must be an
132
+ * integer in [1, 64]; validated at construction. Ignored when
133
+ * `resolveCountry` is used or a custom `resolveIp` is supplied.
134
+ */
135
+ trustedHops?: number;
121
136
  /**
122
137
  * What to do when the country cannot be resolved. Defaults to `false` when
123
138
  * an `allow` list is configured (fail closed — an unknown country is not on
package/dist/geo-block.js CHANGED
@@ -25,6 +25,7 @@
25
25
  * @since 0.37.0
26
26
  */
27
27
  import { ForbiddenError } from "./errors.js";
28
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
28
29
  /** @internal Validate + normalise a configured country code, or throw. */
29
30
  function normalizeConfiguredCode(input) {
30
31
  const code = input.trim().toUpperCase();
@@ -38,16 +39,14 @@ function normalizeConfiguredCode(input) {
38
39
  function noIpResolver(_ctx) {
39
40
  return undefined;
40
41
  }
41
- /** @internal Read the leading `X-Forwarded-For` / `X-Real-IP` hop. */
42
- function forwardedIpResolver(ctx) {
43
- const headers = ctx.request.headers;
44
- const forwarded = headers.get("x-forwarded-for");
45
- if (forwarded) {
46
- const first = forwarded.split(",")[0]?.trim();
47
- if (first)
48
- return first;
49
- }
50
- return ctx.request.headers.get("x-real-ip") ?? undefined;
42
+ /**
43
+ * @internal Read the client IP `hops` entries from the right of
44
+ * `X-Forwarded-For` (falling back to `X-Real-IP`). The right side is written
45
+ * by the operator's own proxy chain and is therefore the spoof-resistant
46
+ * side — see {@link resolveForwardedClientIp}.
47
+ */
48
+ function forwardedIpResolver(hops) {
49
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
51
50
  }
52
51
  /**
53
52
  * Block or allow requests by client country. Daloy ships no GeoIP database;
@@ -90,12 +89,10 @@ export function geoBlock(opts) {
90
89
  const hasLookup = typeof opts.lookupCountry === "function";
91
90
  const hasResolve = typeof opts.resolveCountry === "function";
92
91
  if (hasLookup === hasResolve) {
93
- throw new Error('geoBlock(): exactly one of "lookupCountry" or "resolveCountry" must ' +
94
- "be provided.");
92
+ throw new Error('geoBlock(): exactly one of "lookupCountry" or "resolveCountry" must ' + "be provided.");
95
93
  }
96
94
  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".');
95
+ throw new Error(`geoBlock(): invalid mode ${JSON.stringify(opts.mode)}; expected ` + '"block" or "log".');
99
96
  }
100
97
  const allow = new Set((opts.allow ?? []).map(normalizeConfiguredCode));
101
98
  const deny = new Set((opts.deny ?? []).map(normalizeConfiguredCode));
@@ -107,8 +104,8 @@ export function geoBlock(opts) {
107
104
  const onBlock = opts.onBlock;
108
105
  const lookupCountry = opts.lookupCountry;
109
106
  const resolveCountry = opts.resolveCountry;
110
- const resolveIp = opts.resolveIp ??
111
- (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
107
+ const hops = resolveForwardedTrust("geoBlock()", opts);
108
+ const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : 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;
@@ -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
  }
@@ -686,9 +683,7 @@ export async function verifyMessage(opts) {
686
683
  return fail("key_not_found");
687
684
  let keyMaterial;
688
685
  let pinnedAlg;
689
- if (resolved instanceof Uint8Array ||
690
- isCryptoKey(resolved) ||
691
- isJsonWebKey(resolved)) {
686
+ if (resolved instanceof Uint8Array || isCryptoKey(resolved) || isJsonWebKey(resolved)) {
692
687
  keyMaterial = resolved;
693
688
  }
694
689
  else {
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { _resetCrashHandlersForTests } from "./app.js";
6
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
7
7
  export { _resetIndeterminateEnvWarningForTests } from "./app.js";
8
8
  export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, AsyncAPIRouteOptions, HealthRouteOptions, CspReportRouteOptions, MetricsRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
9
- export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
9
+ export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, resolveForwardedClientIp, } from "./conn-info.js";
10
10
  export type { BehindProxyConfig, ConnInfo } from "./conn-info.js";
11
11
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
12
12
  export type { SubdomainsOptions, SubdomainsResult } from "./subdomains.js";
@@ -19,8 +19,8 @@ export type { StandardSchemaV1 } from "./schema.js";
19
19
  export { validate, isStandardSchema } from "./schema.js";
20
20
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
21
21
  export type { ChangeSeverity, OpenAPIChange, OpenAPIDiffResult } from "./openapi-diff.js";
22
- export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
23
- export type { McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
22
+ export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_ERROR_CODES, MCP_MAX_REQUEST_STATE_LENGTH, MCP_META_KEYS, MCP_MODERN_ERA_MIN_VERSION, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, isModernProtocolVersion, mcpRoutes, validateMcpInput, } from "./mcp.js";
23
+ export type { McpCacheHints, McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpImplementation, McpInputRequest, McpInputRequests, McpInputRequiredResult, McpInputResponses, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpProtocolEra, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
24
24
  export { readBodyLimited, safeJsonParse, safeJsonParseLimited, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
25
25
  export type { WebhookHmacAlgorithm } from "./security.js";
26
26
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.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
@@ -5,13 +5,13 @@ export { findRoutesMissingResponseBodySchema } from "./app.js";
5
5
  export { _resetCrashHandlersForTests } from "./app.js";
6
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
7
7
  export { _resetIndeterminateEnvWarningForTests } from "./app.js";
8
- export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
8
+ export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, resolveForwardedClientIp, } from "./conn-info.js";
9
9
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
10
10
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
11
11
  export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
12
12
  export { validate, isStandardSchema } from "./schema.js";
13
13
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
14
- export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
14
+ export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_ERROR_CODES, MCP_MAX_REQUEST_STATE_LENGTH, MCP_META_KEYS, MCP_MODERN_ERA_MIN_VERSION, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, isModernProtocolVersion, mcpRoutes, validateMcpInput, } from "./mcp.js";
15
15
  export { readBodyLimited, safeJsonParse, safeJsonParseLimited, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
16
16
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
17
17
  export { etag } from "./etag.js";
@@ -45,10 +45,10 @@ export { httpBearerScheme, httpBasicScheme, apiKeyScheme, oauth2Scheme, openIdCo
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";
@@ -109,8 +109,22 @@ export interface IpReputationOptions {
109
109
  /**
110
110
  * Trust `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Only
111
111
  * enable behind a trusted proxy that overwrites these headers.
112
+ *
113
+ * When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
114
+ * entry — the one your immediate proxy appended — never the
115
+ * attacker-influenceable leftmost one. Behind more than one proxy hop, set
116
+ * {@link trustedHops} instead.
112
117
  */
113
118
  trustProxyHeaders?: boolean;
119
+ /**
120
+ * Declare exactly how many proxy hops sit between Daloy and the public
121
+ * internet. Implies proxy-header trust and reads the client IP that many
122
+ * entries from the right of `X-Forwarded-For` via
123
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so attacker-prepended
124
+ * entries on the left cannot dodge the denylist. Must be an integer in
125
+ * [1, 64]; validated at construction.
126
+ */
127
+ trustedHops?: number;
114
128
  /**
115
129
  * `"block"` (default) throws a {@link ForbiddenError} on a match; `"log"`
116
130
  * only invokes {@link IpReputationOptions.onMatch} and lets the request
@@ -46,7 +46,8 @@
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 { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
50
+ import { compileCidrMatcher, matchesMatcher, parseIp } from "./ip-restriction.js";
50
51
  const DEFAULT_REFRESH_MS = 60 * 60_000;
51
52
  const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
52
53
  const DEFAULT_MESSAGE = "IP address not permitted";
@@ -113,15 +114,13 @@ export function urlFeed(url, opts = {}) {
113
114
  },
114
115
  };
115
116
  }
116
- function forwardedIpResolver(ctx) {
117
- const headers = ctx.request.headers;
118
- const forwarded = headers.get("x-forwarded-for");
119
- if (forwarded) {
120
- const first = forwarded.split(",")[0]?.trim();
121
- if (first)
122
- return first;
123
- }
124
- return ctx.request.headers.get("x-real-ip") ?? undefined;
117
+ /**
118
+ * @internal Read the client IP `hops` entries from the right of
119
+ * `X-Forwarded-For` (falling back to `X-Real-IP`) — the spoof-resistant side
120
+ * of the header; see {@link resolveForwardedClientIp}.
121
+ */
122
+ function forwardedIpResolver(hops) {
123
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops);
125
124
  }
126
125
  function noIpResolver(_ctx) {
127
126
  return undefined;
@@ -158,7 +157,8 @@ export function ipReputation(opts) {
158
157
  throw new Error("ipReputation(): fetchTimeoutMs must be a positive integer.");
159
158
  }
160
159
  const message = opts.message ?? DEFAULT_MESSAGE;
161
- const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
160
+ const hops = resolveForwardedTrust("ipReputation()", opts);
161
+ const resolveIp = opts.resolveIp ?? (hops !== undefined ? forwardedIpResolver(hops) : noIpResolver);
162
162
  // Last-known-good compiled denylist, one entry per feed so a single feed's
163
163
  // failed refresh doesn't drop the others.
164
164
  let compiled = opts.feeds.map((f) => ({ name: f.name, v4: [], v6: [] }));
@@ -42,8 +42,22 @@ export interface IpRestrictionOptions {
42
42
  * to `false` because those headers are client-spoofable unless every
43
43
  * request reaches Daloy through a proxy chain you control. Pair with
44
44
  * `new App({ trustProxy: true })` in production.
45
+ *
46
+ * When enabled, the resolver reads the **rightmost** `X-Forwarded-For`
47
+ * entry — the one your immediate proxy appended — never the
48
+ * attacker-influenceable leftmost one, so a spoofed left entry cannot
49
+ * bypass an allow-list or dodge a deny. Behind more than one proxy hop,
50
+ * set {@link trustedHops} instead.
45
51
  */
46
52
  trustProxyHeaders?: boolean;
53
+ /**
54
+ * Declare exactly how many proxy hops sit between Daloy and the public
55
+ * internet. Implies proxy-header trust and reads the client IP that many
56
+ * entries from the right of `X-Forwarded-For` via
57
+ * {@link "./conn-info.js".resolveForwardedClientIp}. Must be an integer in
58
+ * [1, 64]; validated at construction.
59
+ */
60
+ trustedHops?: number;
47
61
  /**
48
62
  * Response message when a request is rejected. Defaults to
49
63
  * `"IP address not permitted"`. Avoid echoing the client IP back —