@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.
- package/README.md +25 -14
- package/dist/adapters/bun.js +1 -2
- package/dist/adapters/node.js +16 -30
- package/dist/app.d.ts +5 -1
- package/dist/app.js +74 -6
- package/dist/auto-ban.d.ts +16 -0
- package/dist/auto-ban.js +20 -12
- package/dist/bot-guard.d.ts +14 -0
- package/dist/bot-guard.js +12 -12
- package/dist/cli.js +9 -6
- package/dist/concurrency-limit.d.ts +14 -0
- package/dist/concurrency-limit.js +18 -9
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +65 -0
- package/dist/conn-info.js +99 -4
- package/dist/errors.js +2 -5
- package/dist/etag.js +12 -2
- package/dist/geo-block.d.ts +15 -0
- package/dist/geo-block.js +14 -19
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.js +3 -8
- package/dist/index.d.ts +5 -5
- package/dist/index.js +4 -4
- package/dist/ip-reputation.d.ts +14 -0
- package/dist/ip-reputation.js +11 -11
- package/dist/ip-restriction.d.ts +14 -0
- package/dist/ip-restriction.js +7 -18
- package/dist/jwt.js +12 -14
- package/dist/logger.js +1 -3
- package/dist/mcp.d.ts +305 -34
- package/dist/mcp.js +554 -49
- package/dist/middleware.d.ts +31 -1
- package/dist/middleware.js +21 -19
- package/dist/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/safe-redirect.js +3 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/waf.js +40 -8
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- 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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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;
|
package/dist/conn-info.d.ts
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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 [
|
|
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);
|
package/dist/geo-block.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
111
|
-
|
|
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;
|
package/dist/http-signatures.js
CHANGED
|
@@ -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";
|
package/dist/ip-reputation.d.ts
CHANGED
|
@@ -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
|
package/dist/ip-reputation.js
CHANGED
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
*/
|
|
47
47
|
import { ForbiddenError } from "./errors.js";
|
|
48
48
|
import { fetchGuard } from "./fetch-guard.js";
|
|
49
|
-
import {
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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: [] }));
|
package/dist/ip-restriction.d.ts
CHANGED
|
@@ -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 —
|