@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 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(req: Request, opts?: { trustProxy?: boolean }): string;
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.62",
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')?.split(',')[0].trim() ||
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
  );