@webjsdev/server 0.8.62 → 0.8.63
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/index.d.ts +25 -1
- package/package.json +1 -1
- package/src/rate-limit.js +50 -5
package/index.d.ts
CHANGED
|
@@ -635,6 +635,21 @@ export declare function rateLimit(opts?: {
|
|
|
635
635
|
* that STRIPS inbound `X-Forwarded-For` before adding its own.
|
|
636
636
|
*/
|
|
637
637
|
trustProxy?: boolean;
|
|
638
|
+
/**
|
|
639
|
+
* Name the ONE forwarded header that carries the visitor, e.g.
|
|
640
|
+
* `'cf-connecting-ip'` behind Cloudflare. Requires `trustProxy: true`, and
|
|
641
|
+
* when set it is the only wire header consulted (falling back to the stamped
|
|
642
|
+
* peer, then `_anon_`).
|
|
643
|
+
*
|
|
644
|
+
* Behind a CDN this is usually required for the limiter to work at all. The
|
|
645
|
+
* default chain takes the leftmost `X-Forwarded-For` entry, which behind
|
|
646
|
+
* Cloudflare is Cloudflare's EGRESS address, not the visitor, and since that
|
|
647
|
+
* is pinned per connection the limiter hands out one bucket per connection.
|
|
648
|
+
* The framework will not prefer `CF-Connecting-IP` on its own: Cloudflare
|
|
649
|
+
* overwrites it, so it is unforgeable behind Cloudflare and forgeable
|
|
650
|
+
* anywhere else, which makes the right choice a property of your topology.
|
|
651
|
+
*/
|
|
652
|
+
clientIpHeader?: string;
|
|
638
653
|
}): Middleware;
|
|
639
654
|
/** Parse a window string (`'1m'`, `'30s'`) to milliseconds. */
|
|
640
655
|
export declare function parseWindow(w: number | string): number;
|
|
@@ -645,8 +660,17 @@ export declare function parseWindow(w: number | string): number;
|
|
|
645
660
|
* `trustProxy: true` reads the forwarded-IP headers instead, UNLESS
|
|
646
661
|
* `WEBJS_NO_TRUST_PROXY=1` is set, which overrides the option back to the
|
|
647
662
|
* stamped peer and logs once per process.
|
|
663
|
+
*
|
|
664
|
+
* `header` names the one forwarded header to trust (`'cf-connecting-ip'`
|
|
665
|
+
* behind Cloudflare) and, when given, is the only wire header read. Use it
|
|
666
|
+
* whenever a CDN sits in front: the default chain's leftmost
|
|
667
|
+
* `X-Forwarded-For` entry is then the CDN's egress address rather than the
|
|
668
|
+
* visitor.
|
|
648
669
|
*/
|
|
649
|
-
export declare function clientIp(
|
|
670
|
+
export declare function clientIp(
|
|
671
|
+
req: Request,
|
|
672
|
+
opts?: { trustProxy?: boolean; header?: string },
|
|
673
|
+
): string;
|
|
650
674
|
/** Stamp the socket remote address onto a request for `clientIp` to read. */
|
|
651
675
|
export declare function stampRemoteIp(req: Request, remoteAddress: string): void;
|
|
652
676
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.63",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/rate-limit.js
CHANGED
|
@@ -40,6 +40,7 @@ let warnedProxyOverride = false;
|
|
|
40
40
|
* message?: string,
|
|
41
41
|
* store?: import('./cache.js').CacheStore,
|
|
42
42
|
* trustProxy?: boolean,
|
|
43
|
+
* clientIpHeader?: string,
|
|
43
44
|
* }} opts
|
|
44
45
|
* @returns {(req: Request, next: () => Promise<Response>) => Promise<Response>}
|
|
45
46
|
*/
|
|
@@ -50,13 +51,17 @@ export function rateLimit(opts = {}) {
|
|
|
50
51
|
const keyPrefix = typeof opts.key === 'string' ? opts.key : '';
|
|
51
52
|
const message = opts.message ?? 'Too Many Requests';
|
|
52
53
|
const trustProxy = opts.trustProxy === true;
|
|
54
|
+
// The header carrying the visitor, when the app knows which one that is.
|
|
55
|
+
// Inert without `trustProxy: true`, since naming a wire header to trust IS
|
|
56
|
+
// the trust decision and must not be grantable by a second option.
|
|
57
|
+
const header = typeof opts.clientIpHeader === 'string' ? opts.clientIpHeader : undefined;
|
|
53
58
|
// Use the provided store, or fall back to the global cache store.
|
|
54
59
|
// Whatever was set via `setStore()` at app startup (in-memory by default).
|
|
55
60
|
const store = opts.store || null;
|
|
56
61
|
|
|
57
62
|
return async function rateLimitMiddleware(req, next) {
|
|
58
63
|
const s = store || getStore();
|
|
59
|
-
const raw = keyFn ? await keyFn(req) : clientIp(req, { trustProxy });
|
|
64
|
+
const raw = keyFn ? await keyFn(req) : clientIp(req, { trustProxy, header });
|
|
60
65
|
const key = `rl:${keyPrefix}${raw}`;
|
|
61
66
|
|
|
62
67
|
const count = await s.increment(key, windowMs);
|
|
@@ -185,10 +190,42 @@ export function propagateTrustedRemoteIp(src, dst) {
|
|
|
185
190
|
* leaving them in disagreement buckets every visitor behind that proxy onto
|
|
186
191
|
* one key.
|
|
187
192
|
*
|
|
193
|
+
* `header` names the ONE forwarded header to trust, and it is the option a
|
|
194
|
+
* CDN deployment needs (#1389). The default chain reads the leftmost
|
|
195
|
+
* `X-Forwarded-For` entry first, which behind Cloudflare is CLOUDFLARE'S
|
|
196
|
+
* EGRESS address rather than the visitor: Cloudflare pins an egress IP per
|
|
197
|
+
* connection, so a limiter keyed on it gives one bucket per connection, which
|
|
198
|
+
* counts down convincingly and limits nobody. The visitor is in
|
|
199
|
+
* `CF-Connecting-IP`, and naming it here is how the app says so.
|
|
200
|
+
*
|
|
201
|
+
* The framework does NOT reorder the default chain to prefer that header,
|
|
202
|
+
* because which header is trustworthy is a property of the TOPOLOGY, not of
|
|
203
|
+
* the framework. Cloudflare overwrites `CF-Connecting-IP`, so it is
|
|
204
|
+
* unforgeable behind Cloudflare and forgeable everywhere else; preferring it
|
|
205
|
+
* globally would let a client on an nginx or bare-Railway deploy outrank the
|
|
206
|
+
* `X-Forwarded-For` the real proxy set. So the app names its header and owns
|
|
207
|
+
* the claim. When `header` is set it is the only forwarded header consulted,
|
|
208
|
+
* falling back to the stamped peer and then `_anon_`, and a blank value falls
|
|
209
|
+
* through rather than becoming a shared literal key.
|
|
210
|
+
*
|
|
188
211
|
* @param {Request} req
|
|
189
|
-
* @param {{ trustProxy?: boolean }} [opts]
|
|
212
|
+
* @param {{ trustProxy?: boolean, header?: string }} [opts]
|
|
190
213
|
* @returns {string}
|
|
191
214
|
*/
|
|
215
|
+
/**
|
|
216
|
+
* First entry of a forwarded-IP header, trimmed, or `''` when there is nothing
|
|
217
|
+
* usable. A blank value must FALL THROUGH rather than resolve: an empty string
|
|
218
|
+
* as a bucket key is one key shared by every visitor whose proxy sent the
|
|
219
|
+
* header empty, which is a limiter that throttles strangers together.
|
|
220
|
+
*
|
|
221
|
+
* @param {string | null | undefined} raw
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
function firstForwardedEntry(raw) {
|
|
225
|
+
if (!raw) return '';
|
|
226
|
+
return raw.split(',')[0].trim();
|
|
227
|
+
}
|
|
228
|
+
|
|
192
229
|
export function clientIp(req, opts = {}) {
|
|
193
230
|
if (opts.trustProxy === true && !proxyIsTrusted() && !warnedProxyOverride) {
|
|
194
231
|
warnedProxyOverride = true;
|
|
@@ -201,10 +238,18 @@ export function clientIp(req, opts = {}) {
|
|
|
201
238
|
);
|
|
202
239
|
}
|
|
203
240
|
if (opts.trustProxy === true && proxyIsTrusted()) {
|
|
241
|
+
if (opts.header) {
|
|
242
|
+
// One named header, and nothing else from the wire. A chain is still
|
|
243
|
+
// split on the comma so a proxy that appends to the named header cannot
|
|
244
|
+
// turn the key into a growing string, which would mint a fresh bucket per
|
|
245
|
+
// hop and reproduce the very failure this option exists to fix.
|
|
246
|
+
const named = req.headers.get(String(opts.header).toLowerCase());
|
|
247
|
+
return firstForwardedEntry(named) || trustedRemoteIp(req) || '_anon_';
|
|
248
|
+
}
|
|
204
249
|
return (
|
|
205
|
-
req.headers.get('x-forwarded-for')
|
|
206
|
-
req.headers.get('cf-connecting-ip') ||
|
|
207
|
-
req.headers.get('x-real-ip') ||
|
|
250
|
+
firstForwardedEntry(req.headers.get('x-forwarded-for')) ||
|
|
251
|
+
req.headers.get('cf-connecting-ip')?.trim() ||
|
|
252
|
+
req.headers.get('x-real-ip')?.trim() ||
|
|
208
253
|
trustedRemoteIp(req) ||
|
|
209
254
|
'_anon_'
|
|
210
255
|
);
|