Skip to content

Commit 5f6c2f1

Browse files
docs: describe the built-in inbound rate limiter, server.security.rateLimit and server.trustProxy (#25328)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D4iG9zsMgxi6WCwGKNA4uH9)_ Fixes #24740 Clause-②: no Metadata-protocol: none ## What changed `docs/HARDENING.md`, `content/docs/deployment/production-readiness.mdx` and the rate-limit callout in `content/docs/api/wire-format.mdx` described rate limiting as a primitive the runtime does not wire, to be added at the adapter layer, and never named the two keys an operator actually sets. Since the inbound limiter landed that is false. The three pages now say what the runtime does, each claim read against `origin/main` at `9dc1f42d5`: | Claim written | Rests on | | --- | --- | | Built in, off by default; nothing registered when not armed | `packages/runtime/src/security/inbound-rate-limit.ts:89`, `packages/runtime/src/dispatcher-plugin.ts:917-922`, `packages/spec/src/shared/http.zod.ts:221` | | Authored as `server.security.rateLimit` (`enabled`, `windowMs`, `maxRequests`), forwarded by `objectstack serve` | `packages/spec/src/stack.zod.ts:906`, `packages/spec/src/system/stack-server.zod.ts:148`, `packages/cli/src/commands/serve.ts:4785-4787` | | Defaults 100 per 60000 ms when enabled with no numbers | `packages/spec/src/shared/http.zod.ts:226,231`, `inbound-rate-limit.ts:91-92` | | Bucket capacity `maxRequests`, refill `maxRequests / (windowMs / 1000)` per second | `inbound-rate-limit.ts:108-111` | | Global middleware on `http.server`; `OPTIONS` never metered; warns when no `http.server` | `dispatcher-plugin.ts:906-942`, `inbound-rate-limit.ts:259,289` | | 429 + `Retry-After` (seconds) + `RATE_LIMIT_EXCEEDED`; no `X-RateLimit-*` header anywhere | `inbound-rate-limit.ts:342-352`, pinned in `inbound-rate-limit.test.ts:212-234`; `git grep -i X-RateLimit -- 'packages/**/*.ts' ':!**/*.test.ts'` = 0 hits | | Zero or negative budget refused at `defineStack`; unknown keys (`keyBy`, `store`) refused | `stack-server.zod.ts:101-118`, `http.zod.ts:194-214` | | Boot line `Inbound rate limit armed` with budget and `trustProxy` | `dispatcher-plugin.ts:924-931` | | Key: principal first, else caller address; one shared unknown bucket with one warning | `inbound-rate-limit.ts:149-163,313-323` | | Address: peer by default; under `server.trustProxy` the leftmost `X-Forwarded-For`, then `X-Real-IP`, else the peer | `packages/plugins/plugin-auth/src/client-address.ts:69-88`, `client-address-forwarding.ts:24` | | `server.trustProxy` defaults `false`; the same value drives the auth allow-list, `last_login_ip` and per-endpoint budgets | `stack-server.zod.ts:203-231`, `serve.ts:4273` | | Counters in the kernel `cache` service, resolved at consume time; per-process fallback warned once | `inbound-rate-limit.ts:194-226,275-283`, `packages/plugins/plugin-auth/src/rate-limit-storage.ts:194-225` | | Under `objectstack serve` `cache` is always on and in-memory (per process); `adapter: 'redis'` refuses to start | `packages/spec/src/kernel/platform-capabilities.ts:351`, `packages/core/src/capability-providers.ts:84-88`, `packages/services/service-cache/src/cache-service-plugin.ts:80-89` | | Read-modify-write: slight over-admit at a boundary across nodes | `inbound-rate-limit.ts:204-208` | | Endpoint budgets meter in a separate namespace | `stack-server.zod.ts:54-58` | | `DEFAULT_RATE_LIMITS` is reference material nothing reads | `packages/runtime/src/security/rate-limit.ts:229-247` | The old Hono recipe (keyed on `X-Forwarded-For` unconditionally) and the Redis `RateLimitStore` paragraph are gone. A compact recipe survives under **Per-class budgets**, the one thing the built-in single bucket does not do, keyed through `resolveRateLimitKey` so its trust rule is the runtime's. The `HARDENING.md` TL;DR row keeps "Off (opt-in)" (accurate: off by default) and now points at `server.security.rateLimit`; the audit checklist line and the go-live checklist now name the built-in keys. The CORS half of `HARDENING.md` is untouched (another card). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `4a59f49`: 41 commands, all exit 0; `--ran` reconciliation 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN. Docs only, nothing published from any package: `skip-changeset`. ## Acceptance notes - `content/docs/api/wire-format.mdx` was a third page repeating the same stale claim (and promising `X-RateLimit-*` headers the runtime never sends); named to the seat before editing, which agreed. - Out of scope, not fixed here: the per-process fallback warning in `packages/plugins/plugin-auth/src/rate-limit-storage.ts:219-221` prescribes "Redis via @objectstack/service-cache", which `cache-service-plugin.ts:80-86` refuses to start. Reported to the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01XyLjAesp3Fkx794wqMb4hK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c79d92 commit 5f6c2f1

3 files changed

Lines changed: 177 additions & 54 deletions

File tree

‎content/docs/api/wire-format.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,5 +605,5 @@ When the batch is not atomic and some records fail, each failing entry carries a
605605
| `ETag` | Entity tag for conditional requests |
606606

607607
<Callout type="info">
608-
**Rate limiting is opt-in:** ObjectStack ships a token-bucket `RateLimiter` primitive (`@objectstack/runtime`), but it is **not** wired into the default REST response path — a deployment must add it at the adapter layer. Only when a deployment does so will responses carry `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` (and a `429` with `Retry-After` once the limit is hit).
608+
**Rate limiting is built in and off by default:** a stack that declares `server.security.rateLimit` with `enabled: true` has every request metered against a per-caller token bucket. A caller over budget gets `429` with a `Retry-After` header (seconds) and `error.code: "RATE_LIMIT_EXCEEDED"`. No `X-RateLimit-*` headers are sent on any response. See [HARDENING.md § Rate limiting](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md#rate-limiting).
609609
</Callout>

‎content/docs/deployment/production-readiness.mdx‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defaults, adapter recipes, go-live checklist) follow the deep-dive links.
1717
| :--- | :--- | :--- |
1818
| Security response headers (CSP / XCTO / X-Frame-Options / …) | **On** | [HARDENING.md](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md) |
1919
| HSTS | Off (opt-in once TLS is confirmed) | HARDENING.md |
20-
| Rate limiting (token bucket) | Off (opt-in via adapter) | HARDENING.md |
20+
| Inbound rate limit (token bucket) | Built in, off until `server.security.rateLimit.enabled` | [HARDENING.md § Rate limiting](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md#rate-limiting) |
2121
| CSRF | Adapter-layer (bearer-auth = N/A) | HARDENING.md |
2222
| Auth / session / JWT lifecycle | On via `@objectstack/plugin-auth` (better-auth) | HARDENING.md |
2323
| Metrics (counter / histogram / gauge) | Noop (no adapter) | [OBSERVABILITY.md](https://github.com/objectstack-ai/objectstack/blob/main/docs/OBSERVABILITY.md) |
@@ -97,18 +97,49 @@ send budget). Declare none and the family sits at 0 under normal traffic, so
9797
treat a cache hit-rate panel as reporting on your configuration before you
9898
treat it as reporting on your cache.
9999

100-
Rate limiting is the one piece you wire at the adapter layer (Fastify
101-
preHandler, Hono middleware, etc.) because that's where you have
102-
reliable access to the caller's IP and authenticated identity. See
103-
the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md#rate-limiting).
100+
Rate limiting is built into the runtime and needs no adapter code — it is
101+
off until the stack declares a budget:
102+
103+
```ts
104+
export default defineStack({
105+
// …
106+
server: {
107+
security: {
108+
rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 300 },
109+
},
110+
trustProxy: true, // only behind a proxy that overwrites X-Forwarded-For
111+
},
112+
});
113+
```
114+
115+
`objectstack serve` then meters every request the server routes (CORS
116+
preflights excepted) against one token bucket per caller, and answers an
117+
over-budget caller `429` with `Retry-After`. The bucket is keyed by the
118+
signed-in principal, and by the caller's address for anonymous traffic. That
119+
address is the connection's peer address unless `server.trustProxy` is
120+
`true`, in which case the leftmost `X-Forwarded-For` entry (else
121+
`X-Real-IP`) is believed — so set it only when a reverse proxy you control
122+
overwrites that header on every request; behind a proxy with it unset, all
123+
anonymous traffic shares the proxy's one bucket. The counters live in the
124+
kernel `cache` service, which `objectstack serve` serves in memory, per
125+
process: on several nodes the effective limit is the budget times the node
126+
count until a shared `cache` service is registered. Key rules, multi-node
127+
and per-class budgets:
128+
[HARDENING.md § Rate limiting](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md#rate-limiting).
104129

105130
## Go-live checklist
106131

107132
- [ ] HSTS enabled (after TLS is confirmed and you accept the lock-in).
108133
- [ ] CSP tuned for your serving topology (deny-all API, looser for
109134
SPA-on-same-origin).
110-
- [ ] Rate limit configured at the adapter for `/auth/*`, write
111-
verbs, and read verbs (auth bucket should be ~10/min/IP).
135+
- [ ] `server.security.rateLimit` enabled with a budget sized for your
136+
traffic; the boot log shows `Inbound rate limit armed`. Sign-in can take its
137+
own tighter budget (`rate_limit_max` / `rate_limit_window_seconds` in
138+
Setup → Authentication).
139+
- [ ] `server.trustProxy` matches the topology: `true` only behind a reverse
140+
proxy that overwrites `X-Forwarded-For`, unset otherwise.
141+
- [ ] Multi-node: a shared `cache` service is registered, or each node
142+
enforces the budget on its own (the in-memory default is per process).
112143
- [ ] Metrics adapter wired; `/metrics` endpoint (Prom) or OTel
113144
exporter producing data; alerts wired for error rate + p95
114145
latency per route.

‎docs/HARDENING.md‎

Lines changed: 138 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ adapter layer for a production deployment.
1313
| -------------------------------------- | --------------------- | ------------------------------ |
1414
| Security response headers (CSP/XCTO/…) | **On** | `@objectstack/runtime` |
1515
| HSTS | Off (opt-in) | `securityHeaders.hsts: true` |
16-
| Token-bucket rate limit | Off (opt-in) | `RateLimiter` primitive |
16+
| Inbound rate limit (token bucket) | Off (opt-in) | `server.security.rateLimit` |
1717
| CSRF | Adapter-layer concern | `hono/secure-headers` / `hono/csrf` |
1818
| Auth (better-auth) | On | `@objectstack/plugin-auth` |
1919
| Auth hardening (ADR-0069) | Off (opt-in) | Setup → Authentication |
@@ -59,74 +59,164 @@ an upstream reverse proxy is already setting them — verify with
5959

6060
## Rate limiting
6161

62-
The runtime exposes a token-bucket primitive but does **not** auto-wire
63-
it into the dispatcher plugin. Reason: in-memory limits behave poorly
64-
behind a load balancer without sticky sessions, so the decision to
65-
enable belongs at the adapter layer where you know your topology.
62+
The runtime ships an inbound rate limiter. It is built in and **off by
63+
default**: with no budget declared nothing is registered, so an unmetered
64+
deployment pays no per-request cost. Declare a budget on the stack's
65+
`server:` block and `objectstack serve` arms it:
6666

6767
```ts
68-
import { RateLimiter, DEFAULT_RATE_LIMITS } from '@objectstack/runtime';
69-
70-
const auth = new RateLimiter(DEFAULT_RATE_LIMITS.auth); // 10 req / min / IP
71-
const write = new RateLimiter(DEFAULT_RATE_LIMITS.write); // 60 req / min / IP
72-
const read = new RateLimiter(DEFAULT_RATE_LIMITS.read); // 600 req / min / IP
68+
import { defineStack } from '@objectstack/spec';
69+
70+
export default defineStack({
71+
// …
72+
server: {
73+
security: {
74+
rateLimit: {
75+
enabled: true,
76+
windowMs: 60_000, // the window, in MILLISECONDS
77+
maxRequests: 300, // requests per caller per window
78+
},
79+
},
80+
trustProxy: true, // ONLY behind a proxy you control (see below)
81+
},
82+
});
7383
```
7484

75-
### Hono recipe
85+
What the runtime does with it:
86+
87+
- The limiter is global middleware on the `http.server` service, so every
88+
request the server routes is metered — REST data, auth, metadata and every
89+
other mount. CORS preflights (`OPTIONS`) are never metered.
90+
- It is a token bucket: capacity `maxRequests`, refilled at
91+
`maxRequests / (windowMs / 1000)` tokens per second. A caller can burst one
92+
full window's worth and sustains exactly the declared rate. `enabled: true`
93+
with no numbers meters 100 requests per 60 000 ms.
94+
- A caller over budget gets `429` with a `Retry-After` header (seconds) and
95+
the standard error envelope, `error.code: "RATE_LIMIT_EXCEEDED"`. No
96+
`X-RateLimit-*` headers are sent.
97+
- `maxRequests` or `windowMs` of `0` or less is refused at `defineStack`, with
98+
the fix in the message; to turn the limiter off, set `enabled: false`. An
99+
unknown key in the budget is refused too — the key and the counter store
100+
are not authorable (see below).
101+
- It is **one bucket per caller** across all routes, not one per traffic
102+
class (see [Per-class budgets](#per-class-budgets)).
103+
- Boot logs `Inbound rate limit armed` with the budget and the `trustProxy`
104+
value — the line to look for after a deploy.
105+
106+
Composing the kernel yourself instead of `objectstack serve`, pass the same
107+
two values to the dispatcher plugin:
108+
`createDispatcherPlugin({ rateLimit: { budget: { enabled: true, windowMs: 60_000, maxRequests: 300 }, trustProxy: false } })`.
109+
It needs an `http.server` service; without one it warns at start that
110+
nothing can be metered.
111+
112+
### Who a caller is: the key, and `server.trustProxy`
113+
114+
The bucket is keyed by **who**, falling back to **where** only when there is
115+
no who:
116+
117+
| Request | Bucket key |
118+
| --- | --- |
119+
| Carries a valid session | `principal:<user id>` — one abusive session cannot spend another user's budget, and users behind one NAT do not throttle each other |
120+
| Anonymous, `server.trustProxy` unset (the default) | `ip:<transport peer address>` — the socket's own address, which a client cannot forge |
121+
| Anonymous, `server.trustProxy: true` | `ip:<address>` — the **leftmost** `X-Forwarded-For` entry, else `X-Real-IP`, else the peer address when neither header is present |
122+
| Anonymous, no address can be established | one shared `ip:unknown` bucket, with a warning logged once — it over-throttles rather than failing open |
123+
124+
Set `server.trustProxy: true` **only** when a reverse proxy you control
125+
**overwrites** `X-Forwarded-For` on every inbound request (nginx:
126+
`proxy_set_header X-Forwarded-For $remote_addr;`). A proxy that *appends*
127+
(`$proxy_add_x_forwarded_for`) leaves the leftmost entry — the one read —
128+
whatever the client sent. Getting it wrong costs in either direction:
129+
130+
- **Behind a proxy, left unset:** every anonymous caller has the proxy's
131+
address, so all anonymous traffic shares one bucket.
132+
- **Set without such a proxy:** `X-Forwarded-For` is caller input. A client
133+
mints a fresh bucket per request (limit bypassed) or claims a victim's
134+
address and spends their budget (limit weaponised).
135+
136+
The same value drives every consumer of the client address: the
137+
authentication plugin's IP allow-list (`allowed_ip_ranges`) and
138+
`last_login_ip` audit, and the budget of every declared endpoint
139+
(`apis[].rateLimit`). Composing the kernel yourself, hand it to `AuthPlugin`
140+
too (`trustProxy` option); `objectstack serve` does that for you.
141+
142+
### Multi-node: the counters need a shared store
143+
144+
The buckets live in the kernel `cache` service, resolved when the first
145+
request is metered, so a cache plugin that registers late is still found.
146+
Under `objectstack serve` the `cache` service is always on and is served by
147+
`@objectstack/service-cache`, whose only working adapter is **in-memory** —
148+
per process. On one node that is exact. On N nodes each counts on its own,
149+
so the effective limit is the budget **multiplied by N**, and nothing about
150+
the deployment looks wrong.
151+
152+
For one limit across nodes, register a `cache` service backed by a store
153+
every node shares (Redis, Memcached, …) that implements the
154+
[`ICacheService` contract](../content/docs/kernel/contracts/cache-service.mdx)
155+
— see [Registering Services](../content/docs/kernel/services.mdx#registering-services).
156+
`CacheServicePlugin({ adapter: 'redis' })` is not that: its Redis adapter is a
157+
skeleton and the plugin refuses to start with it. Boot logs which store the
158+
limiter bound to: `inbound rate-limit buckets bound to the kernel cache
159+
service — enforced against ONE store across nodes iff the cache is shared`,
160+
or, with no `cache` service at all, a warning that it fell back to a
161+
per-process store. Counting is read-modify-write, so two nodes can both admit
162+
the last request at a window boundary — a slight over-admit, never a
163+
per-node limit.
164+
165+
### Per-class budgets
166+
167+
The built-in limiter is one budget for all traffic. Two tighter budgets
168+
already exist without writing middleware:
169+
170+
- **Sign-in and other auth endpoints:** `rate_limit_max` /
171+
`rate_limit_window_seconds` in **Setup → Authentication** (see
172+
[Authentication hardening](#authentication-hardening-adr-0069)), counted in
173+
the same `cache` service.
174+
- **One declared endpoint:** its own `rateLimit` in `apis[]`, metered in a
175+
separate namespace, independently of the server budget.
176+
177+
Composing the Hono adapter yourself and wanting per-class buckets on top
178+
(say 10/min for `/auth/*`, 60/min for writes, 600/min for reads), key them
179+
with the runtime's own resolver so the trust rule is the same one the
180+
built-in limiter applies. `RateLimiter` counts in process, so this layer is
181+
per node:
76182

77183
```ts
78184
import { Hono } from 'hono';
79-
import { secureHeaders } from 'hono/secure-headers';
80185
import { objectStackMiddleware } from '@objectstack/hono';
81-
import { RateLimiter, DEFAULT_RATE_LIMITS } from '@objectstack/runtime';
186+
import { RateLimiter, DEFAULT_RATE_LIMITS, resolveRateLimitKey } from '@objectstack/runtime';
82187

83-
const app = new Hono();
84-
app.use('*', secureHeaders()); // CSP / X-Content-Type-Options / X-Frame-Options / …
188+
const TRUST_PROXY = false; // the same value as `server.trustProxy`
85189

86190
const buckets = {
87-
auth: new RateLimiter(DEFAULT_RATE_LIMITS.auth),
88-
write: new RateLimiter(DEFAULT_RATE_LIMITS.write),
89-
read: new RateLimiter(DEFAULT_RATE_LIMITS.read),
191+
auth: new RateLimiter(DEFAULT_RATE_LIMITS.auth), // 10 req / min
192+
write: new RateLimiter(DEFAULT_RATE_LIMITS.write), // 60 req / min
193+
read: new RateLimiter(DEFAULT_RATE_LIMITS.read), // 600 req / min
90194
};
91195

92-
// Rate-limit BEFORE the dispatcher middleware so rejected requests never reach it.
196+
const app = new Hono();
93197
app.use('/api/v1/*', async (c, next) => {
94-
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
198+
const { key } = resolveRateLimitKey({
199+
headers: c.req.header(),
200+
// @hono/node-server exposes the Node request as `c.env.incoming`.
201+
remoteAddress: c.env?.incoming?.socket?.remoteAddress,
202+
trustProxy: TRUST_PROXY,
203+
});
95204
const bucket = c.req.path.startsWith('/api/v1/auth/') ? 'auth'
96-
: ['POST','PUT','PATCH','DELETE'].includes(c.req.method) ? 'write'
205+
: ['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method) ? 'write'
97206
: 'read';
98-
const decision = buckets[bucket].consume(`${ip}:${bucket}`);
99-
c.header('X-RateLimit-Remaining', String(decision.remaining));
207+
const decision = buckets[bucket].consume(`${key}:${bucket}`);
100208
if (!decision.allowed) {
101-
c.header('Retry-After', String(Math.ceil(decision.retryAfterMs / 1000)));
209+
c.header('Retry-After', String(Math.max(1, Math.ceil(decision.retryAfterMs / 1000))));
102210
return c.json({ error: 'Too many requests' }, 429);
103211
}
104212
await next();
105213
});
106-
107214
app.use('/api/v1/*', objectStackMiddleware(kernel)); // dispatcher last
108-
export default app; // Cloudflare Workers / Bun / Deno / Node (@hono/node-server)
109215
```
110216

111-
For multi-instance deploys, replace the default `MemoryStore` with a
112-
Redis-backed `RateLimitStore` implementation. The interface is small —
113-
`get(key) / set(key, state) / prune(olderThanMs)` — so a 30-line Redis
114-
wrapper is enough.
115-
116-
### Trust the proxy or don't
117-
118-
The dispatcher's default key extractor reads `x-forwarded-for[0]`, which
119-
is **only safe** if your edge proxy strips client-supplied values and
120-
sets its own. If you can't guarantee that, override `keyFn` to use the
121-
socket address:
122-
123-
```ts
124-
new RateLimiter(DEFAULT_RATE_LIMITS.auth, {
125-
// ...
126-
});
127-
// then in your hook:
128-
const key = req.socket.remoteAddress; // ignore X-Forwarded-For
129-
```
217+
This recipe has no session in hand, so it keys anonymous-style (by address)
218+
unless you pass `principalId` yourself. `DEFAULT_RATE_LIMITS` is reference
219+
material: nothing in the runtime reads it.
130220

131221
## CSRF
132222

@@ -251,7 +341,9 @@ Run the production hardening checklist before each release:
251341

252342
- [ ] `securityHeaders` enabled (curl any endpoint, confirm CSP/XCTO/HSTS)
253343
- [ ] HSTS turned on after TLS is verified
254-
- [ ] Rate limit wired at the adapter, with `trust proxy` configured correctly
344+
- [ ] `server.security.rateLimit` enabled with a sized budget; `server.trustProxy`
345+
matches the topology (`true` only behind a proxy that overwrites
346+
`X-Forwarded-For`); multi-node: the `cache` service is shared
255347
- [ ] `revokeSessionsOnPasswordReset: true`
256348
- [ ] CORS origin list is explicit, not `*`
257349
- [ ] `enforceProjectMembership: true` on scoped routes

0 commit comments

Comments
 (0)