@alxia/rate-limit 0.4.0 → 0.4.2

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 CHANGED
@@ -46,8 +46,20 @@ routes declared after it, and also a request no route matches: it is counted,
46
46
  and past the limit answers the 429 before the 404. Put it in a `group` to
47
47
  count only some routes: a path-scoped `use('/api', …)` cannot give `rateLimit` to the context.
48
48
 
49
- Behind a proxy, give the app an `ip` option that reads the header it sets:
50
- `alxia({ ip: (request) => request.headers.get('x-real-ip') ?? undefined })`.
49
+ Behind a proxy, the connection is the proxy: give the app an `ip` option that
50
+ reads the client from the header the proxy appends to, with core's `forwardedIp`:
51
+
52
+ ```ts
53
+ import { alxia, forwardedIp } from '@alxia/core';
54
+
55
+ const app = alxia({ ip: forwardedIp({ trusted: 1 }) }) // one proxy in front
56
+ .use(rateLimit({ limit: 100, windowMs: 60_000 }));
57
+ ```
58
+
59
+ Never key by the first entry of `X-Forwarded-For` (`split(',')[0]`): the client
60
+ writes it, so a new value in each request is a new allowance. `forwardedIp`
61
+ reads the entry your proxy appended
62
+ ([Serving](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)).
51
63
 
52
64
  ## Across processes
53
65
 
@@ -63,12 +75,12 @@ app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { nam
63
75
 
64
76
  A store that counts by a rate of its own declares it as `policy: { limit,
65
77
  windowMs }`, and `rateLimit({ store })` reads both from it, headers included:
66
- the numbers are written once. `redisStore(handle.limits.api, api)` does, from
67
- the definition `api`. A `limit` or `windowMs` given beside it that differs
78
+ the numbers are written once. `redisStore(handle.limits.api)` does, from
79
+ the bound limit's definition. A `limit` or `windowMs` given beside it that differs
68
80
  throws at declaration.
69
81
 
70
82
  ```ts
71
- app.use(rateLimit({ store: redisStore(handle.limits.api, api) }));
83
+ app.use(rateLimit({ store: redisStore(handle.limits.api) }));
72
84
  ```
73
85
 
74
86
  A store of your own implements `RateLimitStore` (and, to count by a rate of
package/docs/guide.md CHANGED
@@ -80,7 +80,7 @@ written once. Without one they are required, and a type error says so.
80
80
  import { redisStore } from '@alxia/redis';
81
81
 
82
82
  // `api` is the defineRateLimit definition the handle wired.
83
- app.use(rateLimit({ store: redisStore(handle.limits.api, api) })); // 100 per 60 s, from `api`
83
+ app.use(rateLimit({ store: redisStore(handle.limits.api) })); // 100 per 60 s, from `api`
84
84
  ```
85
85
 
86
86
  A `limit` or a `windowMs` given beside a policy must equal it, or
@@ -96,15 +96,25 @@ above ten 365-day years (315,360,000,000), on the first request it counts rather
96
96
 
97
97
  By default a limit counts per client address, `ctx.ip`, which is what the
98
98
  app's [`ip` option](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)
99
- reads. Behind a proxy, that is the proxy's address unless you set `ip`:
99
+ reads. Behind a proxy, that is the proxy's address unless you set `ip`, and
100
+ the header to read it from is the client's to write: the first entry of
101
+ `X-Forwarded-For` is whatever the client sent, so a limit keyed by it is
102
+ bypassed with a new value in each request. Use core's `forwardedIp`, which
103
+ reads the entry your own proxies appended, from the right:
100
104
 
101
105
  ```ts
106
+ import { alxia, forwardedIp } from '@alxia/core';
107
+
102
108
  const app = alxia({
103
- ip: (request, server) =>
104
- request.headers.get('x-real-ip') ?? server?.requestIP(request)?.address,
109
+ ip: forwardedIp({ trusted: 1 }), // or ['10.0.0.0/8'], the proxies' own ranges
105
110
  }).use(rateLimit({ limit: 100, windowMs: 60_000 }));
106
111
  ```
107
112
 
113
+ `trusted` is the number of proxies in front of the app (`2` for a CDN and a
114
+ load balancer), or their CIDR ranges, in which case a client that reaches the
115
+ app directly is counted by its own address, whatever header it sends
116
+ ([Serving](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)).
117
+
108
118
  Count by something else — an API key, a token — by returning it from `key`.
109
119
  It may be async. A request whose key is `undefined` is not counted: it passes,
110
120
  gets no rate-limit header, and its route reads `rateLimit` as `undefined`.
package/docs/roadmap.md CHANGED
@@ -39,7 +39,7 @@ Nothing scheduled yet.
39
39
  reads `limit` and `windowMs` from it, both optional in the type, headers
40
40
  included; without a policy they stay required. A `limit` or `windowMs` that
41
41
  differs from the policy throws at declaration. `@alxia/redis`'s
42
- `redisStore(handle.limits.api, api)` is such a store. Every existing form
42
+ `redisStore(handle.limits.api)` is such a store. Every existing form
43
43
  works as before.
44
44
 
45
45
  ### 0.1.0
@@ -22,6 +22,7 @@ nothing of its own; past the limit it answers a 429.
22
22
  **Counting**
23
23
 
24
24
  - [Every client is refused at once](#every-client-is-refused-at-once)
25
+ - [One client is never refused, whatever it sends](#one-client-is-never-refused-whatever-it-sends)
25
26
  - [Nothing is limited, and no `RateLimit-*` header is sent](#nothing-is-limited-and-no-ratelimit--header-is-sent)
26
27
  - [A client makes more than `limit` requests](#a-client-makes-more-than-limit-requests)
27
28
  - [A client is refused before `limit` requests](#a-client-is-refused-before-limit-requests)
@@ -132,7 +133,7 @@ app.use(rateLimit({ limit: Number(Bun.env.RATE_LIMIT ?? 100), windowMs: 60_000 }
132
133
  ### `TypeError: rateLimit: limit … differs from the store's policy of …`
133
134
 
134
135
  **When:** `rateLimit()` is given a `store` that declares its own `policy`
135
- (`redisStore(handle.limits.api, api)`) and a `limit` or a `windowMs` that is
136
+ (`redisStore(handle.limits.api)`) and a `limit` or a `windowMs` that is
136
137
  not the store's. It throws at declaration, so the app fails at startup:
137
138
 
138
139
  ```text
@@ -147,7 +148,7 @@ say what the store does not enforce. (`windowMs` reads the same way.)
147
148
  the rate, change the definition the store reads it from:
148
149
 
149
150
  ```ts
150
- app.use(rateLimit({ store: redisStore(handle.limits.api, api) }));
151
+ app.use(rateLimit({ store: redisStore(handle.limits.api) }));
151
152
  ```
152
153
 
153
154
  ## Responses
@@ -181,16 +182,31 @@ busy client, or a handful of ordinary ones, and everyone gets the 429.
181
182
  **Why:** the default key is `ctx.ip`, the connection's address — the
182
183
  proxy's, the same for every request. All clients share one allowance.
183
184
 
184
- **Fix:** give the app an `ip` option that reads the header your proxy sets,
185
- and only from a proxy you trust:
185
+ **Fix:** give the app an `ip` option that reads the client from the header
186
+ your proxy appends to, with core's `forwardedIp`:
186
187
 
187
188
  ```ts
188
- const app = alxia({
189
- ip: (request, server) =>
190
- request.headers.get('x-real-ip') ?? server?.requestIP(request)?.address,
191
- }).use(rateLimit({ limit: 100, windowMs: 60_000 }));
189
+ import { alxia, forwardedIp } from '@alxia/core';
190
+
191
+ const app = alxia({ ip: forwardedIp({ trusted: 1 }) }).use(
192
+ rateLimit({ limit: 100, windowMs: 60_000 }),
193
+ );
192
194
  ```
193
195
 
196
+ ### One client is never refused, whatever it sends
197
+
198
+ **When:** behind a proxy, a client changes its `X-Forwarded-For` in each
199
+ request and is never answered a 429, or one address is refused for a
200
+ header another client wrote.
201
+
202
+ **Why:** the `ip` option reads the header's first entry
203
+ (`split(',')[0]`). That entry is what the client wrote, so each value is a
204
+ new key. Each proxy appends the address it saw to the right.
205
+
206
+ **Fix:** read from the right, as `forwardedIp` does: `trusted: 1` is the
207
+ last entry, `2` the one before it behind two proxies, and a list of CIDR
208
+ ranges the first entry from the right that is not a proxy of yours.
209
+
194
210
  ### Nothing is limited, and no `RateLimit-*` header is sent
195
211
 
196
212
  **When:** requests past `limit` still answer 200, without a rate-limit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/rate-limit",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Rate limiting for alxia: a 429 with Retry-After past the limit, the remaining allowance typed in the context, pluggable stores",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,12 +41,12 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/core": "^0.5.0",
44
+ "@alxia/core": "^0.6.0",
45
45
  "@types/bun": "^1.4.2",
46
46
  "zod": "^4.2.0"
47
47
  },
48
48
  "peerDependencies": {
49
- "@alxia/core": "^0.5.0",
49
+ "@alxia/core": "^0.6.0",
50
50
  "typescript": "^6.0.3 || ^7.0.0"
51
51
  }
52
52
  }