diff --git a/.changeset/inbound-rate-limit-seam.md b/.changeset/inbound-rate-limit-seam.md new file mode 100644 index 0000000000..5aea84cc8d --- /dev/null +++ b/.changeset/inbound-rate-limit-seam.md @@ -0,0 +1,97 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +"@objectstack/plugin-hono-server": minor +"@objectstack/plugin-auth": patch +"@objectstack/cli": patch +--- + +feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937) + +Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec` +declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any +of them, so an author wrote a budget, it parsed, and nothing happened (#4686). +`@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense, +that the dispatcher called it and short-circuited with 429 — it had **zero call sites** +outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to +tune did not exist (#4937). Neither half was broken; they were simply never connected, and +both were documented as if they were. + +They are connected now, along one narrow path. + +## What you write + +```ts +export default defineStack({ + manifest: { /* … */ }, + server: { + security: { + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 }, + }, + trustProxy: false, + }, +}); +``` + +`server:` is a **new** top-level stack key. Nothing declared it before, so no existing +stack changes behaviour on upgrade — there is no configuration that was inert yesterday +and starts throttling today. + +It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and +nothing else, because those are the two keys with a consumer. It is NOT the nine-key +`HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and +mounting them here would have made seven dead keys writable in one move (their +enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled +budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0` +is refused at `defineStack` rather than at 3am. + +**No `server.port`.** The listening socket belongs to the deployment, not the artifact, and +`objectstack serve -p` already owns it. The precedence rule is recorded in the schema and +the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` > +built-in default.** + +## What happens + +Every inbound request the server routes — REST, dispatcher, service routes, anything +mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`, +refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429** +with a `Retry-After` computed from the bucket itself and the standard error envelope +(`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered. + +The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for +anonymous traffic — so one abusive session cannot spend another user's budget, and +credential-stuffing traffic (which has no principal yet) is still metered per source. That +IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**; +otherwise it is the transport's own peer address. Undeclared, those headers are attacker +input: honouring them by default would hand anyone an unlimited supply of fresh buckets and +let them drain a chosen victim's. + +Counters live in the kernel `cache` service when one is registered, so a multi-node +deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at +consume time so a cache plugin that registers later is still picked up (#4772). With no +cache service at all it falls back to a per-process store and says so once, naming the +consequence: the effective limit becomes the declared budget multiplied by the number of +nodes, and nothing about the deployment looks wrong. + +## Also in this change + +- **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation + passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered + middleware could not read the request, write a response, or decline to continue — a + declared seam with no execution behind it, unnoticed because nothing called it. It now + delivers method/path/query/headers plus the transport peer address + (`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be + registered before the routes it guards; the kernel's two-phase boot makes that automatic + (`init()` before every `start()`). +- **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it + does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous + in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is + now labelled as the reference material it always was rather than as live defaults. + +## Explicitly NOT wired + +`ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain +**known-unwired**. Declaring them still changes nothing. They are not retired here either: +the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one +key of a surface that may yet be implemented would only have to be undone. Tracked, not +silent. diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 42b8a4ffe9..3130b9e810 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -958,39 +958,89 @@ ETag: "abc123def456" ## Rate Limiting - -ObjectStack ships a token-bucket `RateLimiter` primitive -(`@objectstack/runtime`), but emission of the `X-RateLimit-*` response headers -and the `429` envelope below is deployment-specific and not wired into the -default REST response path. Treat the headers and response shape here as the -intended contract. - +Inbound rate limiting is **off unless a stack declares it**, and it is declared in +one place — the stack's `server` block: + +```ts +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { /* … */ }, + server: { + security: { + rateLimit: { + enabled: true, + windowMs: 60_000, // budget window, in MILLISECONDS + maxRequests: 600, // requests permitted per window, per caller + }, + }, + // Believe `X-Forwarded-For` / `X-Real-IP`? Only behind a proxy you control. + trustProxy: false, + }, +}); +``` -When enabled, responses include rate limit headers: +`objectstack serve` / `dev` forward that block to the dispatcher plugin, which arms a +token bucket in front of **every route the server mounts** — not just the dispatcher's +own. `capacity` is `maxRequests` (so a full bucket absorbs one window's worth of +traffic as a burst) and it refills at `maxRequests / (windowMs / 1000)` tokens per +second (so the sustained rate is exactly the declared one). -```http -HTTP/1.1 200 OK -X-RateLimit-Limit: 1000 -X-RateLimit-Remaining: 847 -X-RateLimit-Reset: 1705324800 -``` +### What the bucket is keyed on + +1. **The resolved principal**, when the request carries a valid session. One user + cannot spend another's budget, and users behind a shared NAT do not throttle each + other. +2. **The caller's IP**, for anonymous traffic — the case that most needs a limit + (credential stuffing, scraping) and has no identity yet. + +That IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `server.trustProxy` is +declared `true`**. Left at its default, the address is the transport's own peer +address, which a client cannot forge. This is deliberate: an attacker who can choose +their own `X-Forwarded-For` otherwise gets an unlimited supply of fresh buckets *and* +can drain a chosen victim's. Declare `trustProxy` only when a reverse proxy you +control overwrites those headers on every inbound request. + +CORS preflights (`OPTIONS`) are never metered. + +### When the limit is exceeded -**When limit exceeded:** ```http HTTP/1.1 429 Too Many Requests Retry-After: 45 -X-RateLimit-Limit: 1000 -X-RateLimit-Remaining: 0 -X-RateLimit-Reset: 1705324800 +Content-Type: application/json { - "error": "Rate limit exceeded", - "code": "THROTTLED", - "retry_after": 45 + "success": false, + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.", + "httpStatus": 429, + "details": { "retryAfterSeconds": 45, "resetAt": "2026-08-03T12:00:45.000Z" } + } } ``` -See [Error Handling](/docs/protocol/kernel/error-handling) for more details. +`Retry-After` is computed from the bucket itself, so the wait it advertises is the +wait the bucket will actually take to refill. The body is the standard error envelope +— see [Error Handling](/docs/protocol/kernel/error-handling). + +### Counting across nodes + +Counters live in the kernel `cache` service when one is registered, so a multi-node +deployment enforces **one** budget rather than one per node (ADR-0069 D2). With no +cache service the limiter falls back to a per-process store and says so once, at +`warn`, naming the consequence: until a shared cache is registered the effective limit +is the declared budget multiplied by the number of nodes. + + +**Not implemented, deliberately named rather than implied.** ObjectStack does **not** +emit `X-RateLimit-Limit` / `-Remaining` / `-Reset` headers on successful responses — +only `Retry-After` on a 429. And the per-endpoint `rateLimit` key on +`ApiEndpointSchema` / `ApiEndpointRegistrationSchema` is **not wired to anything**; +declaring it changes nothing today. Its fate travels with the declarative `apis:` +surface as a whole, tracked by [#4936](https://github.com/objectstack-ai/objectstack/issues/4936). + ## Best Practices @@ -1021,14 +1071,17 @@ const tasks = await fetch('/api/data/task?expand=assignee'); ``` ### Respect Rate Limits -✅ **Good:** Check headers and implement backoff +❌ **Bad:** Poll `X-RateLimit-Remaining` — that header is not emitted, so the check +always reads `null` and the backoff never runs. + +✅ **Good:** Handle the 429 and honour `Retry-After` ```javascript const response = await fetch('/api/data/task'); -const remaining = response.headers.get('X-RateLimit-Remaining'); -if (remaining < 10) { - console.warn('Approaching rate limit'); - await sleep(1000); +if (response.status === 429) { + const retryAfter = Number(response.headers.get('Retry-After') ?? 1); + await sleep(retryAfter * 1000); + // …then retry once; the budget refills continuously, so a single wait is enough. } ``` diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx index c3cc7fdce0..1b7d6c1222 100644 --- a/content/docs/references/system/index.mdx +++ b/content/docs/references/system/index.mdx @@ -36,6 +36,7 @@ This section contains all protocol schemas for the system layer of ObjectStack. + diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index 52397a038f..e07635dbfa 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -45,6 +45,7 @@ "doc", "---More---", "metadata-types", - "retry-policy" + "retry-policy", + "stack-server" ] } \ No newline at end of file diff --git a/content/docs/references/system/stack-server.mdx b/content/docs/references/system/stack-server.mdx new file mode 100644 index 0000000000..63fbc62910 --- /dev/null +++ b/content/docs/references/system/stack-server.mdx @@ -0,0 +1,123 @@ +--- +title: Stack Server +description: Stack Server protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +`defineStack(\{ server \})` — the authorable server-facing configuration. + +## Why this is NOT `HttpServerConfigSchema` + +`[system/http-server.zod.ts](/docs/references/system/http-server)` declares nine keys (`port`, `host`, `cors`, + +`requestTimeout`, `bodyLimit`, `compression`, `security`, `static`, + +`trustProxy`). #4938 measured them: **none had a runtime reader and none was + +reachable from any authoring surface** — `stack.zod.ts` had no `server:` key, + +so the whole shape was unwritable as well as unread. Mounting it wholesale + +here would have made eight dead keys authorable in one move, which is the + +declared-≠-enforced defect (Prime Directive #10) manufactured on purpose. + +So this schema is deliberately NARROW: it carries only keys an executor + +actually consumes, and it grows one key at a time, each arriving with its + +consumer. Today that is exactly two: + +| key | consumed by | + +|---|---| + +| `security.rateLimit` | `createDispatcherPlugin` → the inbound token bucket (`@objectstack/runtime` `security/inbound-rate-limit.ts`) — an over-budget caller gets `429` + `Retry-After` | + +| `trustProxy` | the same limiter's IP resolution — see below | + +The other seven `HttpServerConfigSchema` keys stay unreachable, and their + +enforce-or-remove fate is tracked by #4938. Adding one here without an + +executor re-opens the hole this narrowness exists to close. + +## What `server:` is NOT for + +**Deployment knobs stay on the CLI.** There is no `server.port` / `server.host` + +on purpose: the listening socket is a property of *where* a stack runs, not of + +the stack itself, and it is already owned by `objectstack serve -p ` / + +`PORT`. Two authorities for one number is how a config becomes advisory. If a + +future need does add `server.port`, the precedence is settled in advance and + +recorded here so it cannot be re-litigated per-caller: **the CLI flag wins over + +`server:`, and `server:` wins over the built-in default** — an operator + +overriding a port at the command line must not be silently overruled by a file + +baked into the artifact. + +Related: #4910 (this seam), #4937 (the limiter that documented an execution + +chain it never had), #4936 (`apis:` endpoint-level `rateLimit`, still + +unwired), ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). + + +**Source:** `packages/spec/src/system/stack-server.zod.ts` + + +## TypeScript Usage + +```typescript +import { ServerRateLimitConfigSchema, StackServerConfigSchema, StackServerSecuritySchema } from '@objectstack/spec/system'; +import type { ServerRateLimitConfig, StackServerConfig, StackServerSecurity } from '@objectstack/spec/system'; + +// Validate data +const result = ServerRateLimitConfigSchema.parse(data); +``` + +--- + +## ServerRateLimitConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | ✅ | Enable rate limiting | +| **windowMs** | `integer` | ✅ | Time window in milliseconds | +| **maxRequests** | `integer` | ✅ | Max requests per window | + + +--- + +## StackServerConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **security** | `{ rateLimit?: object }` | optional | Server-level security configuration. Today: the global inbound rate limit. | +| **trustProxy** | `boolean` | ✅ | Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is the transport's own peer address, which a client cannot forge. Consumed by the inbound rate limiter when `server.security.rateLimit.enabled` is set. | + + +--- + +## StackServerSecurity + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Global inbound rate limit. When `enabled`, every inbound request consumes from a token bucket derived from this budget (capacity = `maxRequests`, refill = `maxRequests / (windowMs / 1000)` tokens per second); an empty bucket answers 429 with a `Retry-After` header. The bucket is keyed by the RESOLVED PRINCIPAL, falling back to the caller IP for anonymous traffic — so one abusive session cannot exhaust another user's budget, and credential-stuffing traffic (which has no principal yet) is still metered per source. See `server.trustProxy` for how that IP is determined. | + + +--- + diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 9a2a42f32d..b3cd569cb4 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1959,6 +1959,24 @@ export default class Serve extends Command { // (env-native auth IS the membership — ADR-0024 D9) by setting // `api.enforceProjectMembership: false`. Undefined → dispatcher default. const enforceProjectMembership = apiConfig.enforceProjectMembership; + // [#4910] The stack's top-level `server:` block — deliberately narrow: + // only keys with a consumer are declared, and both of these have one in + // the dispatcher's inbound rate limiter. Read here, next to `api:`, for + // the same reason that one is: this is the single place the authored + // stack is turned into plugin configuration. + // + // NOTE the budget is deliberately NOT validated here. An unusable one + // (`maxRequests: 0`) throws out of `createInboundRateLimitMiddleware` + // during the dispatcher plugin's `init()`, which the kernel runs at + // BOOTSTRAP — outside the optional-plugin `catch` below, which only + // guards the `import`/`use` registration. So a nonsense budget fails the + // boot with a prescriptive message instead of silently disarming the + // limiter or, worse, silently dropping the whole dispatcher. + const serverConfig = (config as any).server ?? {}; + const rateLimitConfig = { + ...(serverConfig.security?.rateLimit ? { budget: serverConfig.security.rateLimit } : {}), + trustProxy: serverConfig.trustProxy === true, + }; // [#3963] Anonymous access to object data is denied unconditionally — // there is no `api.requireAuth` opt-out any more (auth is a kernel // concern; every legitimately session-less surface derives its own narrow @@ -2007,6 +2025,7 @@ export default class Serve extends Command { scoping: { enableProjectScoping, projectResolution }, enforceProjectMembership, observability, + rateLimit: rateLimitConfig, }), ); trackPlugin('Dispatcher'); diff --git a/packages/plugins/plugin-auth/src/rate-limit-storage.ts b/packages/plugins/plugin-auth/src/rate-limit-storage.ts index e17bc933a8..488b56e551 100644 --- a/packages/plugins/plugin-auth/src/rate-limit-storage.ts +++ b/packages/plugins/plugin-auth/src/rate-limit-storage.ts @@ -161,6 +161,17 @@ export interface LazyCounterStoreOptions extends LazyCacheRateLimitStorageOption * self-explanatory when the currency is paid SMS. */ degradedImpact?: string; + /** + * Log-line prefix identifying the SUBSYSTEM that degraded, e.g. `'[auth]'` + * (the default) or `'[dispatcher]'`. + * + * Added in #4910, when the inbound rate limiter started counting through this + * same resolution path: without it the runtime's degradation announced itself + * as `[auth] …`, sending an operator to inspect the auth plugin for a + * dispatcher problem. A misattributed log is worse than a terse one — the + * whole value of these two lines is that they name what to go fix. + */ + logPrefix?: string; } /** @@ -182,6 +193,7 @@ export interface LazyCounterStoreOptions extends LazyCacheRateLimitStorageOption */ export function createLazyCounterStore(opts: LazyCounterStoreOptions): () => Promise { const fallback = new InProcessCounterStore(); + const prefix = opts.logPrefix ?? '[auth]'; let cache: CounterStore | undefined; let boundAnnounced = false; let degradedWarned = false; @@ -196,7 +208,7 @@ export function createLazyCounterStore(opts: LazyCounterStoreOptions): () => Pro if (cache && !boundAnnounced) { boundAnnounced = true; opts.logger?.info?.( - `[auth] ${opts.subject} bound to the kernel cache service — enforced against ONE store across nodes iff the cache is shared (ADR-0069 D2)`, + `${prefix} ${opts.subject} bound to the kernel cache service — enforced against ONE store across nodes iff the cache is shared (ADR-0069 D2)`, ); } } @@ -204,7 +216,7 @@ export function createLazyCounterStore(opts: LazyCounterStoreOptions): () => Pro if (!degradedWarned) { degradedWarned = true; opts.logger?.warn?.( - `[auth] ${opts.subject}: no cache service to count in — falling back to a per-process store. ` + + `${prefix} ${opts.subject}: no cache service to count in — falling back to a per-process store. ` + 'This deployment has no `cache` service registered at all; a multi-node deployment needs a shared cache ' + '(Redis via @objectstack/service-cache) or each node enforces the limit independently (ADR-0069 D2)' + (opts.degradedImpact ? `. ${opts.degradedImpact}` : ''), diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 20c3be769c..f28fc06b1d 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -78,6 +78,26 @@ export interface HonoCorsOptions { maxAge?: number; } +/** + * The transport's peer address for a Hono context, when the runtime exposes one. + * + * `@hono/node-server` puts the Node `IncomingMessage` on `c.env.incoming`, so + * the socket's `remoteAddress` is reachable without adding a dependency on + * `hono/conninfo` (which resolves differently per runtime). Every access is + * guarded: on a runtime that exposes nothing, callers get `undefined` and must + * degrade deliberately rather than key security decisions off a fabricated + * value. + */ +function readRemoteAddress(c: any): string | undefined { + try { + const incoming = c?.env?.incoming; + const address = incoming?.socket?.remoteAddress ?? incoming?.connection?.remoteAddress; + return typeof address === 'string' && address.length > 0 ? address : undefined; + } catch { + return undefined; + } +} + /** * Hono Implementation of IHttpServer */ @@ -93,6 +113,10 @@ export class HonoHttpServer implements IHttpServer { * raw Hono app are intentionally NOT tracked, so they never produce a 405. */ private registeredRoutes: Array<{ method: string; pattern: string }> = []; + /** Registered {@link Middleware}s, in registration order. See `use()`. */ + private middlewares: Array<{ path?: string; handler: Middleware }> = []; + /** Whether the Hono middleware that runs {@link middlewares} is mounted. */ + private middlewareSeamInstalled = false; constructor( private port: number = 3000, @@ -302,22 +326,154 @@ export class HonoHttpServer implements IHttpServer { return Array.from(methods).sort(); } + /** + * Register middleware — see the CONTRACT on `IHttpServer.use` in + * `@objectstack/spec/contracts`. + * + * ## What this used to be, and why it matters (#4910) + * + * Until #4910 both branches here handed the middleware `{} as any` for BOTH + * `req` and `res`, and then ran `if (!nextCalled) await next()` — so a + * middleware could not read the request, could not write a response, and + * could not decline to continue. Every registered middleware was, in + * practice, an `await`ed no-op with a `next()` bolted on. `IHttpServer.use` + * was a declared seam with no execution behind it: exactly the + * declared-≠-enforced shape Prime Directive #10 names, one layer below the + * spec keys #4686 opened on. Nothing production caught it because nothing + * production called it — the inbound rate limiter is the first consumer, and + * building it is what surfaced this. + * + * ## Semantics now + * + * Middlewares run in registration order, before any route handler, and each + * one either: + * + * - calls `next()` — the chain continues; or + * - writes a response (`res.status(...).json(...)` / `.send(...)`) without + * calling `next()` — the chain SHORT-CIRCUITS and that response is + * returned. This is the branch that makes a 429 (or a 401, or a + * maintenance 503) possible at all. + * + * A middleware that does neither is treated as pass-through, so an + * early-return on some condition cannot silently black-hole a request. + * + * ## Two deliberate limits, stated so they are not discovered + * + * - **`req.body` is not populated.** Reading the body here would consume + * the request stream before the route handler that owns it, so a + * middleware sees headers/method/path/query only. Body-dependent policy + * belongs in a route handler or a dispatcher gate stage. + * - **The seam must be mounted before routes, but `use()` need not be + * called before them.** Hono composes the handlers that matched, in + * registration order, so a middleware Hono learns about after a route + * runs after that route's handler — useless for short-circuiting. This + * class therefore mounts ONE Hono middleware (the chain runner) and lets + * `use()` append to the chain it reads per request. {@link + * installMiddlewareSeam} places that runner; `HonoServerPlugin` calls it + * at the end of `init()`, after the transport's own built-ins and before + * any route exists, so every later `use()` — from any plugin, in either + * boot phase — gates everything. A standalone `HonoHttpServer` that never + * calls it gets the runner mounted on its first `use()` instead, and only + * that path carries the register-before-routes requirement. + */ use(pathOrHandler: string | Middleware, handler?: Middleware) { if (typeof pathOrHandler === 'string' && handler) { - this.app.use(pathOrHandler, async (c, next) => { - let nextCalled = false; - const wrappedNext = () => { nextCalled = true; return next(); }; - await handler({} as any, {} as any, wrappedNext); - if (!nextCalled) await next(); - }); + this.middlewares.push({ path: pathOrHandler, handler }); } else if (typeof pathOrHandler === 'function') { - this.app.use('*', async (c, next) => { - let nextCalled = false; - const wrappedNext = () => { nextCalled = true; return next(); }; - await pathOrHandler({} as any, {} as any, wrappedNext); - if (!nextCalled) await next(); - }); + this.middlewares.push({ handler: pathOrHandler }); + } else { + return; } + this.installMiddlewareSeam(); + } + + /** + * Mount the single Hono middleware that runs the registered + * {@link Middleware} chain. Idempotent. + * + * WHERE this is called decides what the seam can gate, and the two callers + * are deliberate: + * + * - **`HonoServerPlugin.init()`, at the end** — after the transport's own + * built-ins (Server-Timing, CORS) so a 429 short-circuit still carries + * CORS headers (otherwise a browser reports an opaque network error + * instead of the status), and before any route exists, since every route + * in the platform is mounted in some plugin's `start()`. From there a + * `use()` at ANY later moment gates the whole server, which is what lets + * the dispatcher install the rate limiter in `start()` — where "no + * http.server" is a settled fact rather than a mid-Phase-1 guess that a + * later plugin could contradict. + * - **the first `use()`** — for a bare `HonoHttpServer` composed without + * the plugin, so the seam is never silently absent. + */ + installMiddlewareSeam(): void { + if (this.middlewareSeamInstalled) return; + this.middlewareSeamInstalled = true; + + this.app.use('*', async (c, next) => { + const chain = this.middlewares + .filter((m) => m.path === undefined || matchesRoutePattern(m.path, c.req.path)) + .map((m) => m.handler); + if (chain.length === 0) return next(); + + const headers = c.req.header() as Record; + const req = { + params: {}, + query: c.req.query(), + // Deliberately absent — see the `use()` contract above. + body: undefined, + headers, + method: c.req.method, + path: c.req.path, + /** + * The transport's own peer address. This is the value a client + * CANNOT forge, which is what makes it the safe default for + * identifying an anonymous caller when no proxy is trusted + * (`server.trustProxy`). `@hono/node-server` exposes the Node + * request as `c.env.incoming`; other Hono runtimes may not, and + * consumers must treat it as optional. + */ + remoteAddress: readRemoteAddress(c), + } as any; + + let responded = false; + let status = 200; + const outHeaders = new Headers(); + let bodyJson: unknown; + let bodyRaw: string | Uint8Array | ArrayBuffer | undefined; + + const res: any = { + status(code: number) { status = code; return res; }, + header(name: string, value: string | string[]) { + for (const v of Array.isArray(value) ? value : [value]) outHeaders.append(name, v); + return res; + }, + json(data: unknown) { responded = true; bodyJson = data; }, + send(data: string | Uint8Array | ArrayBuffer) { responded = true; bodyRaw = data; }, + }; + + let index = 0; + let continued = false; + const run = async (): Promise => { + if (index >= chain.length) { continued = true; return; } + const middleware = chain[index++]; + let nextCalled = false; + await middleware(req, res, async () => { nextCalled = true; await run(); }); + // Neither continued nor answered → treat as pass-through, so a + // middleware cannot black-hole a request by accident. + if (!nextCalled && !responded) await run(); + }; + await run(); + + if (responded && !continued) { + if (bodyJson !== undefined) { + outHeaders.set('Content-Type', 'application/json'); + return new Response(JSON.stringify(bodyJson), { status, headers: outHeaders }); + } + return new Response((bodyRaw ?? '') as any, { status, headers: outHeaders }); + } + return next(); + }); } /** diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index 72f12df0cc..55bda0d50d 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -31,6 +31,10 @@ vi.mock('./adapter', async (importOriginal) => ({ listen: vi.fn(), getPort: vi.fn().mockReturnValue(3000), close: vi.fn(), + // [#4910] The plugin places the IHttpServer middleware seam at the + // end of init(). Real behaviour is covered against the REAL adapter + // in `middleware-seam.test.ts`; here it only has to exist. + installMiddlewareSeam: vi.fn(), getRawApp: vi.fn().mockReturnValue({ get: vi.fn(), use: vi.fn(), diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index f336548e39..0dc86a076a 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -380,6 +380,22 @@ export class HonoServerPlugin implements Plugin { ctx.logger.debug('CORS middleware enabled', { origin: configuredOrigin, credentials }); } } + + // ─── IHttpServer middleware seam ────────────────────────────────────── + // Mounted LAST in init(), and that position is the whole point (#4910). + // + // After the built-ins above, so a middleware that short-circuits (the + // inbound rate limiter's 429) still gets CORS headers applied — a + // browser shown a header-less 429 reports an opaque network error and + // the operator debugs the wrong thing. + // + // Before any route, because every route on this server is mounted in + // some plugin's `start()` (Phase 2) and Phase 1 completes first. Hono + // composes matched handlers in registration order, so this is the last + // moment at which a gate can still precede all of them — and placing it + // here means a consumer's `use()` no longer has to win a race with route + // registration. It can register whenever it has the facts. + this.server.installMiddlewareSeam(); } /** diff --git a/packages/plugins/plugin-hono-server/src/middleware-seam.test.ts b/packages/plugins/plugin-hono-server/src/middleware-seam.test.ts new file mode 100644 index 0000000000..1f62f273ee --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/middleware-seam.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `HonoHttpServer.use()` — the middleware seam, exercised through the real Hono + * app (`app.fetch`), never a mock. + * + * ## Why this file exists (#4910) + * + * `IHttpServer.use` was DECLARED middleware and IMPLEMENTED as a no-op: both + * branches passed `{} as any` for `req` and `res`, then called `next()` + * unconditionally, so a middleware could not read the request, write a + * response, or decline to continue. Nothing caught it because nothing called + * it — `use()` had zero production call sites repo-wide. The inbound rate + * limiter is the first consumer, and it needs exactly the three capabilities + * the stub lacked. Each has an assertion below, so the seam cannot quietly + * regress to a no-op again. + * + * ## Two mounting positions, and why the fixtures differ + * + * Hono composes the handlers that matched in registration order, so what a + * middleware can gate depends on when Hono learned about it. This class mounts + * ONE Hono middleware — the chain runner — and `use()` appends to the chain it + * reads per request, so the question becomes "when was the RUNNER mounted": + * + * - `installMiddlewareSeam()` up front (what `HonoServerPlugin.init()` does) — + * every later `use()` gates the whole server, whenever it happens. + * - never called — the runner is mounted on the first `use()`, so that call + * still has to precede the routes it means to guard. + * + * Both are pinned below, including the negative case, so the decoupling is not + * mistaken for something `use()` provides on its own. + */ + +import { describe, it, expect } from 'vitest'; +import type { IHttpRequest, IHttpResponse } from '@objectstack/core'; + +import { HonoHttpServer } from './adapter'; + +/** A server whose middlewares are registered first, then its routes. */ +function serverWith(...middlewares: Array[0]>) { + const server = new HonoHttpServer(0); + for (const middleware of middlewares) server.use(middleware as never); + server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ ok: true }); }); + server.post('/api/v1/thing', (_req, res) => { res.status(201); res.json({ created: true }); }); + server.get('/other', (_req, res) => { res.status(200); res.send('other'); }); + return server; +} + +const call = (server: HonoHttpServer, path: string, init?: RequestInit) => + server.getRawApp().fetch(new Request(`http://localhost${path}`, init)); + +describe('a middleware can SHORT-CIRCUIT a request', () => { + it('answers with its own status, headers and body when it does not call next()', async () => { + const server = serverWith((_req: IHttpRequest, res: IHttpResponse) => { + res.status(429); + res.header('Retry-After', '7'); + res.json({ success: false, error: { code: 'RATE_LIMIT_EXCEEDED' } }); + }); + + const res = await call(server, '/api/v1/thing'); + expect(res.status).toBe(429); + expect(res.headers.get('retry-after')).toBe('7'); + expect(await res.json()).toEqual({ success: false, error: { code: 'RATE_LIMIT_EXCEEDED' } }); + }); + + it('short-circuits a path that matches NO route (the gate is in front of everything)', async () => { + const server = serverWith((_req: IHttpRequest, res: IHttpResponse) => { + res.status(503); + res.send('maintenance'); + }); + + const res = await call(server, '/not-a-route'); + expect(res.status).toBe(503); + expect(await res.text()).toBe('maintenance'); + }); +}); + +describe('a middleware can PASS THROUGH', () => { + it('reaches the route handler when it calls next()', async () => { + let seen = 0; + const server = serverWith(async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { + seen++; + await next(); + }); + + const res = await call(server, '/api/v1/thing'); + expect(seen).toBe(1); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('treats "neither next() nor a response" as pass-through, never a black hole', async () => { + const server = serverWith(() => { /* forgot both branches */ }); + expect((await call(server, '/api/v1/thing')).status).toBe(200); + }); + + it('runs middlewares in registration order and stops at the first that answers', async () => { + const order: string[] = []; + const server = serverWith( + async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { order.push('a'); await next(); }, + (_req: IHttpRequest, res: IHttpResponse) => { order.push('b'); res.status(418); res.send('no'); }, + async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { order.push('c'); await next(); }, + ); + + const res = await call(server, '/api/v1/thing'); + expect(res.status).toBe(418); + expect(order).toEqual(['a', 'b']); + }); +}); + +describe('a middleware can READ the request', () => { + it('sees method, path, query and headers', async () => { + let captured: IHttpRequest | undefined; + const server = serverWith(async (req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { + captured = req; + await next(); + }); + + await call(server, '/api/v1/thing?top=2', { headers: { 'x-probe': 'yes' } }); + + expect(captured?.method).toBe('GET'); + expect(captured?.path).toBe('/api/v1/thing'); + expect(captured?.query).toMatchObject({ top: '2' }); + expect(captured?.headers['x-probe']).toBe('yes'); + }); + + it('does NOT populate the body — the route handler still owns the stream', async () => { + let body: unknown = 'sentinel'; + const server = serverWith(async (req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { + body = req.body; + await next(); + }); + + const res = await call(server, '/api/v1/thing', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }), + }); + + expect(body).toBeUndefined(); + // …and the handler behind it still ran, i.e. the stream was not consumed. + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ created: true }); + }); +}); + +describe('path-scoped middleware', () => { + it('runs only for matching paths', async () => { + const server = new HonoHttpServer(0); + server.use('/api/v1/*', (_req: IHttpRequest, res: IHttpResponse) => { res.status(403); res.send('scoped'); }); + server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ ok: true }); }); + server.get('/other', (_req, res) => { res.status(200); res.send('other'); }); + + expect((await call(server, '/api/v1/thing')).status).toBe(403); + expect((await call(server, '/other')).status).toBe(200); + }); +}); + +describe('installMiddlewareSeam decouples use() from route registration order', () => { + it('gates routes registered AFTER it', async () => { + const server = new HonoHttpServer(0); + server.use((_req: IHttpRequest, res: IHttpResponse) => { res.status(429); res.send('nope'); }); + server.get('/late', (_req, res) => { res.status(200); res.send('late'); }); + + expect((await call(server, '/late')).status).toBe(429); + }); + + it('gates routes registered BEFORE the use() call, once the seam was placed up front', async () => { + // This is the composition `HonoServerPlugin` produces: it places the + // seam at the end of its own `init()`, and consumers `use()` later — + // from `start()`, after other plugins have already mounted routes. The + // gate must still cover those routes, or "server-level rate limit" + // would silently mean "whichever routes happened to be registered after + // the limiter", i.e. a different set per deployment. + const server = new HonoHttpServer(0); + server.installMiddlewareSeam(); + server.get('/early', (_req, res) => { res.status(200); res.send('early'); }); + server.use((_req: IHttpRequest, res: IHttpResponse) => { res.status(429); res.send('nope'); }); + + expect((await call(server, '/early')).status).toBe(429); + }); + + it('is idempotent — calling it twice does not double-run the chain', async () => { + let runs = 0; + const server = new HonoHttpServer(0); + server.installMiddlewareSeam(); + server.installMiddlewareSeam(); + server.use(async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { + runs++; + await next(); + }); + server.get('/thing', (_req, res) => { res.status(200); res.send('ok'); }); + + await call(server, '/thing'); + expect(runs).toBe(1); + }); + + it('without the seam placed up front, a use() after routes cannot gate them', async () => { + // Pinned so the requirement above is not mistaken for an accident: the + // decoupling comes from WHERE the seam is mounted, not from `use()` + // itself. Hono composes matched handlers in registration order. + const server = new HonoHttpServer(0); + server.get('/early', (_req, res) => { res.status(200); res.send('early'); }); + server.use((_req: IHttpRequest, res: IHttpResponse) => { res.status(429); res.send('nope'); }); + + expect((await call(server, '/early')).status).toBe(200); + }); +}); + +describe('the seam costs nothing when unused', () => { + it('does not mount a Hono middleware until the first use() call', async () => { + const server = new HonoHttpServer(0); + server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ ok: true }); }); + expect((await call(server, '/api/v1/thing')).status).toBe(200); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts b/packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts new file mode 100644 index 0000000000..1181b6a8ba --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The declared === enforced probe for inbound rate limiting (#4910). + * + * This is the acceptance criterion of #4910 and the answer to #4686: a budget + * an author WRITES, carried through the real composition, producing a real 429 + * on a real socket. Everything else in this change is unit-testable and unit- + * tested; this file is the only place that proves the two ends are connected, + * which is precisely what nobody could prove before — the spec's three + * `RateLimitConfig` embeddings had zero readers (#4686) and the runtime's token + * bucket had zero call sites (#4937). + * + * ## Read the RED control first + * + * The first suite boots the SAME stack with the SAME budget and the wiring + * omitted, and asserts no request is ever refused. That is not a formality: it + * IS `origin/main` before this change, and without it a green "429 observed" + * could come from anywhere — a proxy, a coincidence, an assertion that would + * pass against an unmodified tree. A declared-≠-enforced probe that cannot fail + * on the unfixed code is not a probe. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** The authored budget, exactly as `defineStack({ server })` would carry it. */ +const BUDGET = { enabled: true, windowMs: 60_000, maxRequests: 3 }; + +/** + * Minimal `auth` service: the `x-test-user` header names the principal. Enough + * for the limiter to resolve one, which is all the keying decision needs. + */ +function fakeAuthPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-auth', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: { headers: any }) => { + const uid = typeof headers?.get === 'function' + ? headers.get('x-test-user') + : headers?.['x-test-user']; + return uid ? { user: { id: uid } } : undefined; + }, + }, + }); + }, + }; +} + +/** A `cache` service so counters land in the shared store, as in production. */ +function fakeCachePlugin(): Plugin { + const entries = new Map(); + return { + name: 'com.objectstack.test.fake-cache', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('cache', { + get: async (key: string) => entries.get(key), + set: async (key: string, value: unknown) => { entries.set(key, value); }, + delete: async (key: string) => entries.delete(key), + has: async (key: string) => entries.has(key), + clear: async () => entries.clear(), + stats: async () => ({ hits: 0, misses: 0, keys: entries.size }), + }); + }, + }; +} + +/** + * A plugin that mounts a route in `start()` and is REGISTERED BEFORE the + * dispatcher — so its route exists before the dispatcher installs the limiter. + * Stands in for `@objectstack/rest`, whose `/data` surface is mounted exactly + * this way. If the gate only covered routes registered after it, + * `server.security.rateLimit` would silently mean "some routes". + */ +function earlyRoutePlugin(): Plugin { + return { + name: 'com.objectstack.test.early-route', + version: '1.0.0', + init: async () => { /* nothing */ }, + start: async (ctx: PluginContext) => { + const server = ctx.getService('http.server'); + server.get('/early/route', (_req, res) => { res.status(200); res.json({ early: true }); }); + }, + }; +} + +async function boot(dispatcherConfig: Record) { + // LiteKernel, like `dispatcher-plugin.ready.integration.test.ts`: the same + // two-phase init/start contract the gate placement depends on, without a + // data engine this suite has no use for. Port 0 → an OS-assigned free port. + const kernel = new LiteKernel(); + kernel.use(fakeAuthPlugin()); + kernel.use(fakeCachePlugin()); + kernel.use(new HonoServerPlugin({ port: 0 })); + // Registered — and therefore started — BEFORE the dispatcher, on purpose. + kernel.use(earlyRoutePlugin()); + kernel.use(createDispatcherPlugin({ + prefix: '/api/v1', + securityHeaders: false, + ...dispatcherConfig, + })); + await kernel.bootstrap(); + const httpServer = kernel.getService('http.server'); + return { kernel, baseUrl: `http://127.0.0.1:${httpServer.getPort!()}` }; +} + +async function shutdown(kernel: LiteKernel | undefined) { + if (!kernel) return; + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); +} + +describe('RED control — a declared budget with no wiring never refuses anything', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + // Same budget, deliberately NOT handed to the plugin. This is the state + // of the repo before #4910: parseable, unread, inert. + ({ kernel, baseUrl } = await boot({})); + }, 30_000); + + afterAll(() => shutdown(kernel), 30_000); + + it('serves far past the budget without a single 429', async () => { + const statuses: number[] = []; + for (let i = 0; i < BUDGET.maxRequests + 5; i++) { + statuses.push((await fetch(`${baseUrl}/api/v1/health`)).status); + } + expect(statuses).not.toContain(429); + expect(new Set(statuses)).toEqual(new Set([200])); + }); +}); + +describe('inbound rate limit, wired (#4910)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + ({ kernel, baseUrl } = await boot({ rateLimit: { budget: BUDGET, trustProxy: false } })); + }, 30_000); + + afterAll(() => shutdown(kernel), 30_000); + + it('answers 429 with Retry-After once the declared budget is spent', async () => { + const statuses: number[] = []; + let limited: Response | undefined; + for (let i = 0; i < BUDGET.maxRequests + 2; i++) { + const res = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_budget' } }); + statuses.push(res.status); + if (res.status === 429 && !limited) limited = res; + } + + expect(statuses.slice(0, BUDGET.maxRequests)).toEqual([200, 200, 200]); + expect(statuses.slice(BUDGET.maxRequests)).toEqual([429, 429]); + + expect(limited).toBeDefined(); + const retryAfter = Number(limited!.headers.get('retry-after')); + expect(Number.isFinite(retryAfter)).toBe(true); + expect(retryAfter).toBeGreaterThanOrEqual(1); + + const body = await limited!.json() as { success: boolean; error: Record }; + expect(body.success).toBe(false); + expect(body.error.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(body.error.httpStatus).toBe(429); + }); + + it('gates a route this plugin does NOT own — the budget is server-level, not dispatcher-level', async () => { + // `/nope` is served by nothing: without the gate it 404s. With the gate + // in front of every route it 429s once the caller's bucket is empty, + // which is what makes `server.security.rateLimit` an honest name. A + // limiter bolted onto this plugin's own mounts would leave `/data` (and + // everything else another plugin mounts) unmetered. + const first = await fetch(`${baseUrl}/nope`, { headers: { 'x-test-user': 'usr_unowned' } }); + expect(first.status).toBe(404); + + let last = first; + for (let i = 0; i < BUDGET.maxRequests + 2; i++) { + last = await fetch(`${baseUrl}/nope`, { headers: { 'x-test-user': 'usr_unowned' } }); + } + expect(last.status).toBe(429); + }); + + it('gates a route another plugin mounted BEFORE the limiter was installed', async () => { + // The `@objectstack/rest` shape: a plugin registered earlier mounts its + // routes in `start()`, which runs before the dispatcher's. Registration + // order must not decide which paths are metered. + const first = await fetch(`${baseUrl}/early/route`, { headers: { 'x-test-user': 'usr_early' } }); + expect(first.status).toBe(200); + + let last = first; + for (let i = 0; i < BUDGET.maxRequests + 2; i++) { + last = await fetch(`${baseUrl}/early/route`, { headers: { 'x-test-user': 'usr_early' } }); + } + expect(last.status).toBe(429); + }); + + it('meters each principal against its own bucket', async () => { + // usr_a spends its whole budget … + for (let i = 0; i < BUDGET.maxRequests + 1; i++) { + await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_a' } }); + } + const aDenied = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_a' } }); + expect(aDenied.status).toBe(429); + + // … and usr_b, on the very same connection, is unaffected. + const bAllowed = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_b' } }); + expect(bAllowed.status).toBe(200); + }); + + it('never meters a CORS preflight', async () => { + for (let i = 0; i < BUDGET.maxRequests + 3; i++) { + const res = await fetch(`${baseUrl}/api/v1/health`, { + method: 'OPTIONS', + headers: { origin: 'http://localhost:5173', 'access-control-request-method': 'GET' }, + }); + expect(res.status).not.toBe(429); + } + }); +}); + +describe('trustProxy decides whether a forged header can mint fresh buckets', () => { + it('IGNORES X-Forwarded-For when trustProxy is not declared', async () => { + const { kernel, baseUrl } = await boot({ rateLimit: { budget: BUDGET, trustProxy: false } }); + try { + // Every request claims a DIFFERENT client address. Undeclared, the + // header is worth nothing and all of them share the loopback bucket + // — so the limit still bites. Believing it here is the bypass. + const statuses: number[] = []; + for (let i = 0; i < BUDGET.maxRequests + 2; i++) { + const res = await fetch(`${baseUrl}/api/v1/health`, { + headers: { 'x-forwarded-for': `203.0.113.${i}` }, + }); + statuses.push(res.status); + } + expect(statuses).toContain(429); + } finally { + await shutdown(kernel); + } + }, 30_000); + + it('honours X-Forwarded-For once trustProxy IS declared', async () => { + const { kernel, baseUrl } = await boot({ rateLimit: { budget: BUDGET, trustProxy: true } }); + try { + // Declared, each distinct forwarded address is a distinct caller and + // gets its own budget — the behaviour an operator behind a real + // reverse proxy is asking for. + const statuses: number[] = []; + for (let i = 0; i < BUDGET.maxRequests + 2; i++) { + const res = await fetch(`${baseUrl}/api/v1/health`, { + headers: { 'x-forwarded-for': `203.0.113.${i}` }, + }); + statuses.push(res.status); + } + expect(statuses).not.toContain(429); + + // …and one address that overspends is still cut off. + const repeated: number[] = []; + for (let i = 0; i < BUDGET.maxRequests + 2; i++) { + const res = await fetch(`${baseUrl}/api/v1/health`, { + headers: { 'x-forwarded-for': '198.51.100.1' }, + }); + repeated.push(res.status); + } + expect(repeated).toContain(429); + } finally { + await shutdown(kernel); + } + }, 30_000); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index f6eff73d88..924cba48fb 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -4,14 +4,18 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { DispatcherErrorCode } from '@objectstack/spec/api'; import type { IAuthService } from '@objectstack/spec/contracts'; +import type { CounterStore } from '@objectstack/plugin-auth'; import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js'; import { isServiceServeable } from './service-serveable.js'; import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js'; import { buildApiError } from './error-envelope.js'; import { buildSecurityHeaders, + createInboundRateLimitMiddleware, + type InboundRateLimitBudget, type SecurityHeadersOptions, } from './security/index.js'; +import { resolveSessionData, resolveSessionPrincipalId } from './security/resolve-session-principal.js'; import { NoopMetricsRegistry, NoopErrorReporter, @@ -95,6 +99,59 @@ export interface DispatcherPluginConfig { generateRequestId?: () => string; requestIdHeader?: string; }; + + /** + * Inbound rate limiting, forwarded from the stack's authored + * `server:` block by `objectstack serve` (#4910). + * + * This field is the one `security/rate-limit.ts` used to name in the + * present tense while it did not exist (#4937) — it exists now, and it is + * the only way the limiter is armed: omit it, or leave + * `budget.enabled` false, and no middleware is registered at all + * (zero per-request cost, not a disabled check). + * + * When armed, the plugin installs the limiter as GLOBAL middleware on the + * `http.server` service. Global is load-bearing: + * `server.security.rateLimit` is a SERVER-level budget, and a limiter + * covering only this plugin's own routes while `/data` ran unmetered would + * be the same declared-≠-enforced half-truth the key was introduced to end. + * + * It goes in `start()` rather than `init()` so that "this kernel has no + * `http.server`" is a settled fact when it is reported, not a Phase-1 guess + * a later-initializing transport could contradict (#4771). That is only safe + * because the transport mounts the middleware SEAM at the end of its own + * `init()` — a `use()` at any later point still gates every route, so the + * gate does not have to win a race with route registration to be complete. + * + * Endpoint-level `ApiEndpointSchema.rateLimit` / + * `ApiEndpointRegistrationSchema.rateLimit` are NOT read here. They remain + * KNOWN-UNWIRED and are tracked by #4936, which owns the fate of the whole + * declarative `apis:` face — wiring one key of a surface whose existence is + * still undecided would have to be undone if that decision goes the other + * way. + */ + rateLimit?: { + /** The authored `server.security.rateLimit` budget. */ + budget?: InboundRateLimitBudget; + /** The authored `server.trustProxy`. */ + trustProxy?: boolean; + }; +} + +/** + * `ctx.getService(name)` without the throw. + * + * The kernel's accessor raises for an unregistered name, and every consumer + * here treats absence as a legitimate composition ("no auth in this stack", "no + * cache service yet"). Spelled once so no branch quietly turns a missing + * OPTIONAL service into a boot failure. + */ +function safeGetService(ctx: PluginContext, name: string): T | undefined { + try { + return ctx.getService(name) ?? undefined; + } catch { + return undefined; + } } /** @@ -450,7 +507,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu version: '1.0.0', init: async (_ctx: PluginContext) => { - // Consumer-only plugin — no services registered + // Consumer-only plugin — no services registered. }, start: async (ctx: PluginContext) => { @@ -459,8 +516,57 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server = ctx.getService('http.server'); } catch { // No HTTP server available — skip silently - return; + server = undefined; } + + // ── Inbound rate limit (#4910) ────────────────────────────── + // Installed in `start()`, deliberately. Phase 1 is over, so "no + // `http.server`" is a FACT rather than a mid-boot guess a later + // plugin could contradict — the #4771 class of defect, which is + // exactly what makes the warning below safe to emit. The gate still + // precedes every route because the transport mounts the middleware + // SEAM at the end of its own `init()`; `use()` appends to a chain + // that seam reads per request, so registration order stops mattering + // (see `HonoHttpServer.installMiddlewareSeam`). + const rateLimitMiddleware = createInboundRateLimitMiddleware({ + ...(config.rateLimit?.budget ? { budget: config.rateLimit.budget } : {}), + trustProxy: config.rateLimit?.trustProxy === true, + resolvePrincipalId: (headers) => + resolveSessionPrincipalId( + safeGetService(ctx, 'auth'), + headers as Record, + ), + resolveCache: async () => safeGetService(ctx, 'cache'), + logger: ctx.logger, + }); + // `null` = no budget declared, or declared disabled. Nothing is + // registered at all, so an unmetered deployment pays zero + // per-request cost — not a disabled check, no check. + if (rateLimitMiddleware) { + if (server) { + server.use(rateLimitMiddleware); + const budget = config.rateLimit?.budget; + ctx.logger.info('Inbound rate limit armed', { + maxRequests: budget?.maxRequests ?? 100, + windowMs: budget?.windowMs ?? 60_000, + // NOT `key:` — the logger redacts that field name, and + // `key: ***REDACTED***` tells an operator nothing about + // what the limit is actually keyed on. + keyedBy: 'principal, falling back to caller IP', + trustProxy: config.rateLimit?.trustProxy === true, + }); + } else { + // Absence must be loud (route-ownership rule 3): a stack + // that ASKED to be rate limited and is not must never find + // out from a load test. + ctx.logger.warn( + '[dispatcher] `server.security.rateLimit` is enabled but this kernel has no `http.server` ' + + 'service, so no request can be metered. Mount a transport plugin (e.g. ' + + '@objectstack/plugin-hono-server), or remove the rate-limit declaration.', + ); + } + } + if (!server) return; const kernel = ctx.getKernel(); @@ -1149,15 +1255,12 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // which claims the same thing while saying nothing. const authService = ctx.getService('auth') as IAuthService | undefined; if (!authService) return undefined; - let api: any = authService.api; - if (!api && typeof authService.getApi === 'function') { - api = await authService.getApi(); - } - if (!api?.getSession) return undefined; - const headersInstance = headers instanceof Headers - ? headers - : new Headers(headers as Record); - const sessionData = await api.getSession({ headers: headersInstance }); + // [#4910] The session lookup itself lives in + // `security/resolve-session-principal.ts` — the inbound rate + // limiter asks the same question one kernel phase earlier, + // and two copies of "who is calling" would eventually + // disagree about what counts as authenticated. + const sessionData = await resolveSessionData(authService, headers); const userId: string | undefined = sessionData?.user?.id ?? sessionData?.session?.userId; if (!userId) return undefined; // AI-route req.user permissions (incl. the synthesized `ai_seat`) are diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index 713ab2b570..d3ec5706c7 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -229,6 +229,13 @@ describe('#3842 — no dispatcher module may reintroduce the drift', () => { './domain-handler-registry.ts', './domains/ai.ts', './domains/mcp.ts', + // [#4910] The inbound rate limiter writes a 429 body from a MIDDLEWARE + // rather than a route handler — a fifth way onto this wire surface, and + // therefore a fifth way to reintroduce a numeric `code`. Listed the day + // it was written, which is the whole point of the scan: the suite + // covering only the branches that existed when it was authored is how + // four sites drifted into three parking spots. + './security/inbound-rate-limit.ts', ]; for (const file of MODULES) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2c648b435f..3daf787d6f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -58,20 +58,38 @@ export type { DomainRoute, DomainHandler, DomainRequest, DomainHandlerDeps } fro export { MiddlewareManager } from './middleware.js'; // ── Security primitives ─────────────────────────────────────────────── -// Adapter-agnostic helpers for response hardening (CSP/HSTS/XCTO/…) -// and per-IP token-bucket rate limiting. The dispatcher plugin wires -// security headers automatically; rate limiting is exposed as a -// primitive so adapters can mount it at the appropriate layer (see -// `docs/guide/hardening.md`). +// Adapter-agnostic helpers for response hardening (CSP/HSTS/XCTO/…) and +// token-bucket rate limiting. +// +// The dispatcher plugin wires BOTH automatically: security headers on +// every response it mounts, and — when the stack declares +// `server.security.rateLimit` — the inbound limiter as global middleware +// on the `http.server` service (#4910). The pieces stay exported because +// a host that composes its own transport still needs them; they are not +// exported *instead* of being wired, which is what the previous version +// of this comment claimed while pointing at a guide that did not exist +// (#4937). export { buildSecurityHeaders, type SecurityHeadersOptions, RateLimiter, DEFAULT_RATE_LIMITS, + applyTokenBucket, + bucketIdleTtlSeconds, + type BucketState, type RateLimitBucketConfig, type RateLimitDecision, type RateLimitDefaults, type RateLimitStore, + createInboundRateLimitMiddleware, + deriveBucketConfig, + resolveRateLimitKey, + SharedTokenBucketLimiter, + type InboundRateLimitBudget, + type InboundRateLimitOptions, + type RateLimitKeyInput, + type RateLimitKeyKind, + type RateLimitLogger, } from './security/index.js'; // ── Observability primitives ────────────────────────────────────────── diff --git a/packages/runtime/src/security/inbound-rate-limit.test.ts b/packages/runtime/src/security/inbound-rate-limit.test.ts new file mode 100644 index 0000000000..014e78aaf3 --- /dev/null +++ b/packages/runtime/src/security/inbound-rate-limit.test.ts @@ -0,0 +1,376 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Unit coverage for the inbound rate-limit seam (#4910) — the derivation, the + * key shape, the shared counter, and the 429 the middleware writes. + * + * The end-to-end proof (a declared budget producing a real 429 over a real + * socket) lives in `../dispatcher-plugin.rate-limit.integration.test.ts`. Both + * are needed and neither substitutes for the other: #4937 happened because a + * bucket with excellent unit tests had no call site, and #4686 because a schema + * with excellent parse tests had no reader. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { + createInboundRateLimitMiddleware, + deriveBucketConfig, + resolveRateLimitKey, + SharedTokenBucketLimiter, +} from './inbound-rate-limit.js'; +import type { CounterStore } from '@objectstack/plugin-auth'; + +/** An in-test counter store that also records whether it was ever consulted. */ +function memoryStore() { + const entries = new Map(); + return { + entries, + store: { + async get(key: string) { return entries.get(key) as T | undefined; }, + async set(key: string, value: T) { entries.set(key, value); }, + } satisfies CounterStore, + }; +} + +describe('deriveBucketConfig — authored budget → token bucket', () => { + it('maps maxRequests to capacity and derives the refill rate from the window', () => { + expect(deriveBucketConfig({ enabled: true, maxRequests: 60, windowMs: 60_000 })) + .toEqual({ capacity: 60, refillPerSec: 1 }); + expect(deriveBucketConfig({ enabled: true, maxRequests: 10, windowMs: 1_000 })) + .toEqual({ capacity: 10, refillPerSec: 10 }); + }); + + it('consumes every key the authoring surface exposes', () => { + // Per-key liveness, asserted rather than asserted-about: `enabled` arms + // it, and BOTH numeric keys move the derived bucket. A key that changed + // nothing here would be a silent rider — the defect #4686 opened on. + expect(deriveBucketConfig({ enabled: false, maxRequests: 60, windowMs: 60_000 })).toBeNull(); + expect(deriveBucketConfig(undefined)).toBeNull(); + + const base = deriveBucketConfig({ enabled: true, maxRequests: 60, windowMs: 60_000 })!; + expect(deriveBucketConfig({ enabled: true, maxRequests: 120, windowMs: 60_000 })!.capacity) + .not.toBe(base.capacity); + expect(deriveBucketConfig({ enabled: true, maxRequests: 60, windowMs: 30_000 })!.refillPerSec) + .not.toBe(base.refillPerSec); + }); + + it('applies the schema defaults when only `enabled` reaches the runtime', () => { + expect(deriveBucketConfig({ enabled: true })).toEqual({ capacity: 100, refillPerSec: 100 / 60 }); + }); + + it('refuses a budget that can never admit a request, prescriptively', () => { + expect(() => deriveBucketConfig({ enabled: true, maxRequests: 0 })) + .toThrow(/set `enabled: false`/); + expect(() => deriveBucketConfig({ enabled: true, windowMs: 0 })) + .toThrow(/MILLISECONDS/); + }); +}); + +describe('resolveRateLimitKey — Q3=C, principal first, IP only as a fallback', () => { + it('keys an authenticated caller by principal, ignoring their address entirely', () => { + expect(resolveRateLimitKey({ + principalId: 'usr_1', + remoteAddress: '10.0.0.5', + headers: { 'x-forwarded-for': '9.9.9.9' }, + trustProxy: true, + })).toEqual({ key: 'principal:usr_1', kind: 'principal' }); + }); + + it('gives two principals behind ONE address two buckets', () => { + const a = resolveRateLimitKey({ principalId: 'usr_a', remoteAddress: '10.0.0.5', trustProxy: false }); + const b = resolveRateLimitKey({ principalId: 'usr_b', remoteAddress: '10.0.0.5', trustProxy: false }); + expect(a.key).not.toBe(b.key); + }); + + it('falls back to the transport peer address for anonymous traffic', () => { + expect(resolveRateLimitKey({ remoteAddress: '10.0.0.5', trustProxy: false })) + .toEqual({ key: 'ip:10.0.0.5', kind: 'ip' }); + }); + + it('IGNORES X-Forwarded-For when trustProxy was not declared', () => { + // The security property this key exists to protect: an undeclared proxy + // means the header is attacker input. Believing it would hand every + // caller an unlimited supply of fresh buckets AND let them spend a + // chosen victim's. + expect(resolveRateLimitKey({ + remoteAddress: '10.0.0.5', + headers: { 'x-forwarded-for': '203.0.113.9' }, + trustProxy: false, + })).toEqual({ key: 'ip:10.0.0.5', kind: 'ip' }); + }); + + it('honours X-Forwarded-For once trustProxy IS declared', () => { + expect(resolveRateLimitKey({ + remoteAddress: '10.0.0.5', + headers: { 'x-forwarded-for': '203.0.113.9' }, + trustProxy: true, + })).toEqual({ key: 'ip:203.0.113.9', kind: 'ip' }); + }); + + it('takes the LEFTMOST forwarded entry — the original client, not the last hop', () => { + expect(resolveRateLimitKey({ + headers: { 'x-forwarded-for': '203.0.113.9, 70.41.3.18, 150.172.238.178' }, + trustProxy: true, + }).key).toBe('ip:203.0.113.9'); + }); + + it('accepts X-Real-IP as the fallback forwarded header', () => { + expect(resolveRateLimitKey({ headers: { 'x-real-ip': '198.51.100.7' }, trustProxy: true }).key) + .toBe('ip:198.51.100.7'); + }); + + it('over-throttles rather than failing open when nothing identifies the caller', () => { + expect(resolveRateLimitKey({ trustProxy: false })) + .toEqual({ key: 'ip:unknown', kind: 'unknown' }); + }); +}); + +describe('SharedTokenBucketLimiter — counters live in the shared store (ADR-0069 D2)', () => { + it('spends the bucket down and then denies with a real retry hint', async () => { + const { store } = memoryStore(); + let clock = 1_000_000; + const limiter = new SharedTokenBucketLimiter( + { capacity: 2, refillPerSec: 1 }, + async () => store, + () => clock, + ); + + expect((await limiter.consume('k')).allowed).toBe(true); + expect((await limiter.consume('k')).allowed).toBe(true); + + const denied = await limiter.consume('k'); + expect(denied.allowed).toBe(false); + expect(denied.retryAfterMs).toBe(1000); + expect(denied.resetAt).toBe(clock + 1000); + }); + + it('refills over time, so a denied caller recovers without intervention', async () => { + const { store } = memoryStore(); + let clock = 1_000_000; + const limiter = new SharedTokenBucketLimiter({ capacity: 1, refillPerSec: 1 }, async () => store, () => clock); + + expect((await limiter.consume('k')).allowed).toBe(true); + expect((await limiter.consume('k')).allowed).toBe(false); + clock += 1000; + expect((await limiter.consume('k')).allowed).toBe(true); + }); + + it('counts two nodes sharing a store against ONE budget', async () => { + // The #4772 lesson, as an assertion: with a shared store the effective + // limit is the declared limit; with per-process stores it silently + // becomes `declared × nodes` and nothing looks wrong. + const { store } = memoryStore(); + const clock = () => 1_000_000; + const nodeA = new SharedTokenBucketLimiter({ capacity: 2, refillPerSec: 1 }, async () => store, clock); + const nodeB = new SharedTokenBucketLimiter({ capacity: 2, refillPerSec: 1 }, async () => store, clock); + + expect((await nodeA.consume('principal:u')).allowed).toBe(true); + expect((await nodeB.consume('principal:u')).allowed).toBe(true); + expect((await nodeB.consume('principal:u')).allowed).toBe(false); + }); + + it('treats a foreign value under the key as absent instead of throwing on a live request', async () => { + const { store, entries } = memoryStore(); + entries.set('k', 'not json'); + const limiter = new SharedTokenBucketLimiter({ capacity: 1, refillPerSec: 1 }, async () => store); + expect((await limiter.consume('k')).allowed).toBe(true); + }); +}); + +/** A request/response pair shaped like the `IHttpServer` middleware contract. */ +function fakeExchange(req: Record = {}) { + const captured: { status: number; headers: Record; body?: unknown } = { + status: 200, + headers: {}, + }; + let nexted = false; + const res: any = { + status(code: number) { captured.status = code; return res; }, + header(name: string, value: string) { captured.headers[name] = value; return res; }, + json(data: unknown) { captured.body = data; }, + send(data: unknown) { captured.body = data; }, + }; + return { + req: { method: 'GET', path: '/api/v1/health', headers: {}, query: {}, params: {}, ...req } as any, + res, + captured, + next: async () => { nexted = true; }, + get nexted() { return nexted; }, + }; +} + +describe('createInboundRateLimitMiddleware', () => { + it('registers NOTHING when the stack declared no budget', () => { + expect(createInboundRateLimitMiddleware({ resolveCache: async () => undefined })).toBeNull(); + expect(createInboundRateLimitMiddleware({ + budget: { enabled: false, maxRequests: 1 }, + resolveCache: async () => undefined, + })).toBeNull(); + }); + + it('answers 429 with Retry-After once the bucket is empty', async () => { + const { store } = memoryStore(); + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 1, windowMs: 10_000 }, + resolveCache: async () => store, + })!; + + const first = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(first.req, first.res, first.next); + expect(first.nexted).toBe(true); + expect(first.captured.body).toBeUndefined(); + + const second = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(second.req, second.res, second.next); + expect(second.nexted).toBe(false); + expect(second.captured.status).toBe(429); + expect(Number(second.captured.headers['Retry-After'])).toBeGreaterThanOrEqual(1); + + const body = second.captured.body as { success: boolean; error: Record }; + expect(body.success).toBe(false); + expect(body.error.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(body.error.httpStatus).toBe(429); + }); + + it('meters an authenticated caller separately from an anonymous one on the same address', async () => { + const { store } = memoryStore(); + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 1, windowMs: 10_000 }, + resolvePrincipalId: async (headers) => (headers['x-test-user'] as string | undefined), + resolveCache: async () => store, + })!; + + const anon = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(anon.req, anon.res, anon.next); + expect(anon.nexted).toBe(true); + + // Same address, but a principal — its own bucket, so it is not already + // spent by the anonymous request above. + const authed = fakeExchange({ remoteAddress: '10.0.0.1', headers: { 'x-test-user': 'usr_1' } }); + await middleware(authed.req, authed.res, authed.next); + expect(authed.nexted).toBe(true); + + const anonAgain = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(anonAgain.req, anonAgain.res, anonAgain.next); + expect(anonAgain.captured.status).toBe(429); + }); + + it('never meters a CORS preflight', async () => { + const { store, entries } = memoryStore(); + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 1, windowMs: 10_000 }, + resolveCache: async () => store, + })!; + + for (let i = 0; i < 5; i++) { + const exchange = fakeExchange({ method: 'OPTIONS', remoteAddress: '10.0.0.1' }); + await middleware(exchange.req, exchange.res, exchange.next); + expect(exchange.nexted).toBe(true); + } + // Not merely allowed — never counted, so preflights cannot exhaust the + // budget a real request needs. + expect(entries.size).toBe(0); + }); + + it('treats an auth service that throws as anonymous, not as an outage', async () => { + const { store } = memoryStore(); + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 5, windowMs: 10_000 }, + resolvePrincipalId: async () => { throw new Error('auth is down'); }, + resolveCache: async () => store, + })!; + + const exchange = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(exchange.req, exchange.res, exchange.next); + expect(exchange.nexted).toBe(true); + await expect(store.get('ip:10.0.0.1')).resolves.toBeDefined(); + }); +}); + +describe('degradation is announced once, with consequence and remedy (ADR-0069 D2)', () => { + it('warns exactly once when there is no cache service to count in', async () => { + const warn = vi.fn(); + const info = vi.fn(); + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 10, windowMs: 10_000 }, + resolveCache: async () => undefined, + logger: { warn, info }, + })!; + + for (let i = 0; i < 3; i++) { + const exchange = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(exchange.req, exchange.res, exchange.next); + } + + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0]![0]); + // Names the subsystem that degraded (not `[auth]` — the resolution + // helper is shared with plugin-auth and used to hardcode that prefix), + // the consequence, and the remedy. + expect(message).toMatch(/^\[dispatcher\]/); + expect(message).toMatch(/inbound rate-limit buckets/); + expect(message).toMatch(/MULTIPLIED BY the number of nodes/); + expect(message).toMatch(/Redis via @objectstack\/service-cache/); + }); + + it('announces the healthy bind once instead of staying silent about it', async () => { + const { store } = memoryStore(); + const info = vi.fn(); + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 10, windowMs: 10_000 }, + resolveCache: async () => store, + logger: { info, warn: vi.fn() }, + })!; + + const exchange = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(exchange.req, exchange.res, exchange.next); + await middleware(exchange.req, exchange.res, exchange.next); + + expect(info).toHaveBeenCalledTimes(1); + expect(String(info.mock.calls[0]![0])).toMatch(/bound to the kernel cache service/); + }); + + it('resolves the cache LAZILY, so a cache plugin that registers later still counts (#4772)', async () => { + const { store, entries } = memoryStore(); + let cacheAvailable = false; + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 10, windowMs: 10_000 }, + resolveCache: async () => (cacheAvailable ? store : undefined), + logger: { warn: vi.fn(), info: vi.fn() }, + })!; + + // Boot-time request: no cache yet, counted in the per-process fallback. + const early = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(early.req, early.res, early.next); + expect(entries.size).toBe(0); + + // The cache service comes up … + cacheAvailable = true; + const later = fakeExchange({ remoteAddress: '10.0.0.1' }); + await middleware(later.req, later.res, later.next); + // … and the very next count lands in it, with no restart. + await expect(store.get('ip:10.0.0.1')).resolves.toBeDefined(); + }); + + it('warns once when the transport cannot identify anonymous callers at all', async () => { + const { store } = memoryStore(); + const warn = vi.fn(); + const middleware = createInboundRateLimitMiddleware({ + budget: { enabled: true, maxRequests: 10, windowMs: 10_000 }, + resolveCache: async () => store, + logger: { warn, info: vi.fn() }, + })!; + + for (let i = 0; i < 3; i++) { + const exchange = fakeExchange({}); + await middleware(exchange.req, exchange.res, exchange.next); + } + + const unknownWarnings = warn.mock.calls + .map((c) => String(c[0])) + .filter((m) => m.includes('cannot identify anonymous callers')); + expect(unknownWarnings).toHaveLength(1); + expect(unknownWarnings[0]).toMatch(/trustProxy/); + }); +}); diff --git a/packages/runtime/src/security/inbound-rate-limit.ts b/packages/runtime/src/security/inbound-rate-limit.ts new file mode 100644 index 0000000000..64af1b12b6 --- /dev/null +++ b/packages/runtime/src/security/inbound-rate-limit.ts @@ -0,0 +1,362 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Inbound rate limiting — the seam between the authored + * `server.security.rateLimit` budget and a request that actually gets a 429. + * + * ## Why this file exists (#4910, #4937, #4686) + * + * `packages/spec` declared three `RateLimitConfig` embeddings and the repo had + * ZERO readers for any of them: an author wrote a budget, it parsed, and nothing + * happened (#4686). `runtime/security/rate-limit.ts` held a token bucket whose + * comments claimed, in the present tense, to be wired into the dispatcher — it + * had no call sites at all (#4937). Two halves of one mechanism, neither + * touching the other, each documented as if it did. + * + * This module is the connective tissue the maintainer adjudicated on + * 2026-08-03: a NARROW authorable `server:` key (Q1=B), server-level only + * (Q2=B — endpoint-level `rateLimit` stays knowingly unwired, tracked by + * #4936), keyed principal-first with IP fallback and forwarded headers believed + * only under an explicit `trustProxy` (Q3=C), counting in the kernel cache with + * an announced per-process fallback (Q4=B / ADR-0069 D2). + * + * ## Shape of the thing + * + * ``` + * authored budget derived bucket per-request + * ──────────────── ────────────── ─────────── + * { enabled, windowMs, → { capacity, → resolveRateLimitKey() + * maxRequests } refillPerSec } → consume() → 429? + * ``` + * + * Every key the authoring surface exposes is consumed here — `enabled` arms it, + * `windowMs` + `maxRequests` size it, `trustProxy` decides whose address counts. + * That is the per-key liveness bar #4910 set: no key arrives without its reader. + */ + +import type { Middleware, IHttpRequest, IHttpResponse } from '@objectstack/core'; +import { createLazyCounterStore, type CounterStore } from '@objectstack/plugin-auth'; + +import { buildApiError } from '../error-envelope.js'; +import { + applyTokenBucket, + bucketIdleTtlSeconds, + type BucketState, + type RateLimitBucketConfig, + type RateLimitDecision, +} from './rate-limit.js'; + +/** + * The authored budget, as it arrives from `server.security.rateLimit` + * (`@objectstack/spec` `ServerRateLimitConfigSchema`). Restated structurally + * rather than imported as a type so `packages/runtime` keeps depending on the + * spec's SHAPE, not on a Zod inference chain. + */ +export interface InboundRateLimitBudget { + /** Arm the limiter. `false` (the default) leaves every request unmetered. */ + enabled?: boolean; + /** Budget window in milliseconds. */ + windowMs?: number; + /** Requests permitted per window. */ + maxRequests?: number; +} + +/** + * Derive the token bucket from an authored budget. + * + * The mapping (#4686's, confirmed against `RateLimitConfigSchema` on + * `origin/main`): + * + * capacity = maxRequests — a full bucket absorbs + * one whole window's worth + * of traffic as a burst + * refillPerSec = maxRequests / (windowMs / 1000) — and sustains exactly the + * declared long-run rate + * + * Returns `null` when the budget is absent or `enabled` is not set — "not + * armed" is a normal state, not an error. + * + * Throws when the budget is armed but unusable. `maxRequests: 0` or + * `windowMs: 0` would produce a bucket that can never admit anything or a + * division by zero, and the schema's `.int()` alone does not exclude them. The + * error is prescriptive because it surfaces at boot, where the author can still + * act on it — silently clamping to a default would be the "parses, then does + * something other than what you wrote" failure this whole issue exists to end. + */ +export function deriveBucketConfig(budget: InboundRateLimitBudget | undefined): RateLimitBucketConfig | null { + if (!budget?.enabled) return null; + + const maxRequests = budget.maxRequests ?? 100; + const windowMs = budget.windowMs ?? 60_000; + + if (!Number.isFinite(maxRequests) || maxRequests <= 0) { + throw new Error( + `server.security.rateLimit.maxRequests must be greater than 0 (got ${String(budget.maxRequests)}). ` + + 'A zero budget rejects every request including your own health checks; to turn rate limiting off, ' + + 'set `enabled: false` instead.', + ); + } + if (!Number.isFinite(windowMs) || windowMs <= 0) { + throw new Error( + `server.security.rateLimit.windowMs must be greater than 0 (got ${String(budget.windowMs)}). ` + + 'It is the budget window in MILLISECONDS — 60000 is one minute.', + ); + } + + return { + capacity: maxRequests, + refillPerSec: maxRequests / (windowMs / 1000), + }; +} + +/** How a rate-limit key was determined — reported once at boot, and in tests. */ +export type RateLimitKeyKind = 'principal' | 'ip' | 'unknown'; + +export interface RateLimitKeyInput { + /** Resolved principal id, when the request carried a valid session. */ + principalId?: string; + /** Request headers, lower-cased as every adapter delivers them. */ + headers?: Record; + /** The transport's peer address (`IHttpRequest.remoteAddress`). */ + remoteAddress?: string; + /** `server.trustProxy` — believe forwarded headers. */ + trustProxy: boolean; +} + +function firstHeader( + headers: Record | undefined, + name: string, +): string | undefined { + if (!headers) return undefined; + const raw = headers[name] ?? headers[name.toLowerCase()]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value !== 'string') return undefined; + // `X-Forwarded-For: client, proxy1, proxy2` — the LEFTMOST entry is the + // original client. It is also the entry a client controls, which is exactly + // why reading it at all requires `trustProxy`. + const first = value.split(',')[0]?.trim(); + return first ? first : undefined; +} + +/** + * Q3=C, in code: the bucket is keyed by **who**, and only falls back to + * **where** when there is no who. + * + * - a resolved principal keys its own bucket, so one abusive session cannot + * spend another user's budget, and a user behind a shared NAT is not + * throttled by their neighbours; + * - anonymous traffic — credential stuffing, scraping, the traffic that most + * needs a limit and has no identity yet — keys off the caller address; + * - that address comes from `X-Forwarded-For` / `X-Real-IP` **only** when + * `server.trustProxy` was declared. Untrusted, those headers are attacker + * input: honouring them by default would let anyone mint unlimited buckets + * (bypass) or drain a chosen victim's (weaponise). Undeclared, the address is + * the transport peer, which a client cannot influence. + * + * When neither is available (a runtime that exposes no peer address, no trusted + * header) the key is a single shared `ip:unknown` bucket. That deliberately + * over-throttles rather than failing open: an unlimited unknown is the one + * outcome a rate limiter must never produce, and the caller announces the + * condition once — see {@link createInboundRateLimitMiddleware}. + */ +export function resolveRateLimitKey(input: RateLimitKeyInput): { key: string; kind: RateLimitKeyKind } { + if (input.principalId) return { key: `principal:${input.principalId}`, kind: 'principal' }; + + const forwarded = input.trustProxy + ? firstHeader(input.headers, 'x-forwarded-for') ?? firstHeader(input.headers, 'x-real-ip') + : undefined; + const address = forwarded ?? input.remoteAddress; + + if (address) return { key: `ip:${address}`, kind: 'ip' }; + return { key: 'ip:unknown', kind: 'unknown' }; +} + +/** The stored bucket envelope. Private to this module — every read goes through {@link parseBucket}. */ +interface StoredBucket { + /** tokens */ + t: number; + /** lastRefill (epoch ms) */ + r: number; +} + +/** + * Read back a bucket envelope. Cache adapters differ on whether a value comes + * back as the stored string (Redis) or the original object (memory), so both + * are accepted; anything else is treated as absent, which restarts the bucket + * full rather than throwing on a live request. Same tolerance, and the same + * reason, as `plugin-auth`'s `parseCounter`. + */ +function parseBucket(raw: unknown): BucketState | undefined { + if (raw === undefined || raw === null) return undefined; + let value: unknown = raw; + if (typeof raw === 'string') { + try { value = JSON.parse(raw); } catch { return undefined; } + } + if (typeof value !== 'object' || value === null) return undefined; + const { t, r } = value as Partial; + if (typeof t !== 'number' || !Number.isFinite(t)) return undefined; + if (typeof r !== 'number' || !Number.isFinite(r)) return undefined; + return { tokens: t, lastRefill: r }; +} + +/** + * A token bucket whose state lives in a {@link CounterStore} — the kernel + * `cache` service when one is registered, a bounded per-process map otherwise. + * + * ADR-0069 D2, and the #4772 lesson it was written from: per-instance counters + * mean the effective limit is `declared × instances`, which is a SILENT + * under-enforcement — nothing looks wrong, the limit simply is not the limit. + * The store is resolved at CONSUME time, never at plugin init, because a cache + * plugin can register after this one (that exact ordering bug is #4772). + * + * Like plugin-auth's fixed-window counter this stays read-modify-write in the + * absence of an atomic primitive, so two nodes can both admit a request at the + * boundary and the limit can over-admit slightly under concurrency. That is + * strictly better than counting per node, and the day `ICacheService` grows an + * atomic increment this is where it plugs in. + */ +export class SharedTokenBucketLimiter { + constructor( + private readonly config: RateLimitBucketConfig, + private readonly resolveStore: () => Promise, + private readonly now: () => number = Date.now, + ) {} + + async consume(key: string, cost = this.config.defaultCost ?? 1): Promise { + const store = await this.resolveStore(); + const now = this.now(); + const current = parseBucket(await store.get(key)); + const { state, decision } = applyTokenBucket(current, this.config, now, cost); + const envelope: StoredBucket = { t: state.tokens, r: state.lastRefill }; + await store.set(key, JSON.stringify(envelope), bucketIdleTtlSeconds(this.config)); + return decision; + } +} + +/** Minimal logger surface — matches the kernel logger without importing it. */ +export interface RateLimitLogger { + info?(message: string, meta?: unknown): void; + warn?(message: string, meta?: unknown): void; +} + +export interface InboundRateLimitOptions { + /** The authored `server.security.rateLimit` budget. */ + budget?: InboundRateLimitBudget; + /** The authored `server.trustProxy`. */ + trustProxy?: boolean; + /** + * Resolve the caller's principal id from request headers. Returning + * `undefined` means anonymous, which is a normal outcome and keys by IP. + */ + resolvePrincipalId?: (headers: Record) => Promise; + /** Resolve the kernel `cache` service. Called per consume — see ADR-0069 D2. */ + resolveCache: () => Promise; + logger?: RateLimitLogger; + /** Injectable clock — tests only. */ + now?: () => number; +} + +/** + * HTTP methods that are never metered. + * + * A CORS preflight carries no credentials and performs no work; throttling it + * does not slow an attacker down (they can skip it) but does break every + * cross-origin caller of a legitimately busy app, in a way that surfaces in the + * browser as an opaque CORS failure rather than a 429. + */ +const UNMETERED_METHODS = new Set(['OPTIONS']); + +/** + * Build the inbound rate-limit middleware, or `null` when the budget is not + * armed (so a caller can skip registering anything at all). + * + * The 429 body goes through {@link buildApiError} like every other error on + * this wire surface (#3842), and carries `Retry-After` in seconds — computed + * from the bucket's own `retryAfterMs`, so the number a client is told to wait + * is the number the bucket will actually take to refill. + */ +export function createInboundRateLimitMiddleware(opts: InboundRateLimitOptions): Middleware | null { + const config = deriveBucketConfig(opts.budget); + if (!config) return null; + + const trustProxy = opts.trustProxy === true; + const resolveStore = createLazyCounterStore({ + resolveCache: opts.resolveCache, + ...(opts.logger ? { logger: opts.logger as { info?(m: string): void; warn?(m: string): void } } : {}), + subject: 'inbound rate-limit buckets', + degradedImpact: + 'Until a shared cache is registered the effective limit is the declared budget MULTIPLIED BY the number ' + + 'of nodes, and nothing about the deployment will look wrong.', + logPrefix: '[dispatcher]', + }); + const limiter = new SharedTokenBucketLimiter(config, resolveStore, opts.now ?? Date.now); + + let unknownKeyWarned = false; + + return async (req: IHttpRequest, res: IHttpResponse, next: () => void | Promise) => { + if (UNMETERED_METHODS.has(String(req.method ?? '').toUpperCase())) { + await next(); + return; + } + + let principalId: string | undefined; + if (opts.resolvePrincipalId) { + try { + principalId = await opts.resolvePrincipalId(req.headers ?? {}); + } catch { + // Unresolvable identity is anonymous, not an error — the route's + // own auth gate still runs. Failing the request here would turn + // an auth hiccup into an outage. + principalId = undefined; + } + } + + const { key, kind } = resolveRateLimitKey({ + ...(principalId ? { principalId } : {}), + headers: req.headers ?? {}, + ...(req.remoteAddress ? { remoteAddress: req.remoteAddress } : {}), + trustProxy, + }); + + if (kind === 'unknown' && !unknownKeyWarned) { + unknownKeyWarned = true; + // Functional degradation, not durability — the limit is still + // enforced, just coarsely — so `warn` per the repo's log-level rule. + opts.logger?.warn?.( + '[dispatcher] inbound rate limit cannot identify anonymous callers: this transport exposes no peer ' + + 'address and no trusted forwarded header, so ALL anonymous traffic shares ONE bucket and will ' + + 'throttle each other. Fix by terminating on a proxy that sets X-Forwarded-For and declaring ' + + '`server.trustProxy: true`, or by serving through an adapter that reports the socket address.', + ); + } + + let decision: RateLimitDecision; + try { + decision = await limiter.consume(key); + } catch { + // A store that throws must not take the API down with it. This is + // the one fail-open in the file, and it is bounded: it can only + // happen when the cache backend itself is erroring, at which point + // the deployment has a louder problem than an unmetered request. + await next(); + return; + } + + if (decision.allowed) { + await next(); + return; + } + + const retryAfterSec = Math.max(1, Math.ceil(decision.retryAfterMs / 1000)); + res.status(429); + res.header('Retry-After', String(retryAfterSec)); + res.json({ + success: false, + error: buildApiError({ + message: 'Rate limit exceeded. Retry after the interval in the Retry-After header.', + httpStatus: 429, + details: { retryAfterSeconds: retryAfterSec, resetAt: new Date(decision.resetAt).toISOString() }, + }), + }); + }; +} diff --git a/packages/runtime/src/security/index.ts b/packages/runtime/src/security/index.ts index cfbbf57dec..bb467a6d82 100644 --- a/packages/runtime/src/security/index.ts +++ b/packages/runtime/src/security/index.ts @@ -7,11 +7,25 @@ export { export { RateLimiter, DEFAULT_RATE_LIMITS, + applyTokenBucket, + bucketIdleTtlSeconds, + type BucketState, type RateLimitBucketConfig, type RateLimitDecision, type RateLimitDefaults, type RateLimitStore, } from './rate-limit.js'; +export { + createInboundRateLimitMiddleware, + deriveBucketConfig, + resolveRateLimitKey, + SharedTokenBucketLimiter, + type InboundRateLimitBudget, + type InboundRateLimitOptions, + type RateLimitKeyInput, + type RateLimitKeyKind, + type RateLimitLogger, +} from './inbound-rate-limit.js'; export { API_KEY_PREFIX, hashApiKey, diff --git a/packages/runtime/src/security/rate-limit.ts b/packages/runtime/src/security/rate-limit.ts index 0f903c24bc..e76c37d4b1 100644 --- a/packages/runtime/src/security/rate-limit.ts +++ b/packages/runtime/src/security/rate-limit.ts @@ -1,15 +1,40 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * In-memory token-bucket rate limiter. + * Token-bucket rate limiting — the algorithm, and its synchronous + * in-process front end. * - * Designed to be adapter-agnostic — the dispatcher calls `consume(key)` - * with a request fingerprint (IP, IP+route bucket, or user id) and - * short-circuits with 429 if the bucket is empty. + * ## Read this before trusting a comment in this file (#4937) * - * For production multi-instance deploys, swap the in-memory store via - * `RateLimitStore`. The shape is intentionally narrow so a Redis-backed - * implementation is straightforward. + * Until #4910 the header here asserted, in the present tense, that "the + * dispatcher calls `consume(key)` … and short-circuits with 429", that a + * deployment could "tune via `DispatcherPluginConfig.rateLimit`", and that "the + * dispatcher constructs the key from `${ip}:${bucket}`". **None of it was true.** + * `RateLimiter` had exactly zero call sites outside its own unit test, no + * inbound path answered 429 anywhere in the repo, and `DispatcherPluginConfig` + * had no `rateLimit` field. The file described a design, in the tense reserved + * for behaviour, and cost a full investigation to disprove (#4937). + * + * It is true now, and the sentences below are worded so a future reader can + * check each one against a call site rather than take it on faith: + * + * - `createDispatcherPlugin({ rateLimit })` (`../dispatcher-plugin.ts`) builds + * a limiter and installs it as global middleware on the `http.server` + * service, so every request that server routes passes through it. + * - the key is built by `resolveRateLimitKey` in `./inbound-rate-limit.ts` — + * resolved principal first, caller IP for anonymous traffic — NOT + * `${ip}:${bucket}`. + * - the budget comes from the authored `server.security.rateLimit` block + * (`@objectstack/spec` `StackServerConfigSchema`), forwarded by + * `objectstack serve`. + * + * ## This module vs `./inbound-rate-limit.ts` + * + * The bucket ARITHMETIC lives here, in {@link applyTokenBucket}, and has two + * front ends over it so there is one algorithm and no second code path to + * drift: {@link RateLimiter} (synchronous, in-process — used by embedders and + * by the tests that pin the maths) and the async, shared-store limiter in + * `./inbound-rate-limit.ts` (counters in the kernel cache, ADR-0069 D2). */ export interface RateLimitDecision { @@ -31,12 +56,88 @@ export interface RateLimitBucketConfig { defaultCost?: number; } -interface BucketState { +export interface BucketState { tokens: number; /** Last refill timestamp (ms). */ lastRefill: number; } +/** + * The token-bucket step, as a pure function: refill for elapsed time, then + * spend `cost` if the bucket can afford it. + * + * Extracted (#4910) so the synchronous {@link RateLimiter} and the async + * shared-store limiter in `./inbound-rate-limit.ts` cannot disagree about what + * a bucket does. A divergent second implementation is how "the limit works in + * tests but not in production" happens — the same reasoning + * `plugin-auth/rate-limit-storage.ts` records for its fixed-window counter. + * + * @param state current bucket state, or `undefined` for a bucket that has + * never been touched (starts full). + * @param config capacity + refill rate. + * @param now wall-clock ms. + * @param cost tokens this request wants to spend. + * @returns the state to persist and the decision to answer with. The state is + * returned in BOTH branches on purpose: a denied request must still + * write back its refill, or a caller that retries in a tight loop + * never accrues tokens. + */ +export function applyTokenBucket( + state: BucketState | undefined, + config: RateLimitBucketConfig, + now: number, + cost: number, +): { state: BucketState; decision: RateLimitDecision } { + const { capacity, refillPerSec } = config; + + let next: BucketState; + if (!state) { + next = { tokens: capacity, lastRefill: now }; + } else { + const elapsedSec = (now - state.lastRefill) / 1000; + next = elapsedSec > 0 + ? { tokens: Math.min(capacity, state.tokens + elapsedSec * refillPerSec), lastRefill: now } + : { tokens: state.tokens, lastRefill: state.lastRefill }; + } + + if (next.tokens >= cost) { + next.tokens -= cost; + return { + state: next, + decision: { + allowed: true, + remaining: Math.floor(next.tokens), + retryAfterMs: 0, + resetAt: now + Math.ceil(((capacity - next.tokens) / refillPerSec) * 1000), + }, + }; + } + + const retryAfterMs = Math.ceil(((cost - next.tokens) / refillPerSec) * 1000); + return { + state: next, + decision: { + allowed: false, + remaining: Math.floor(next.tokens), + retryAfterMs, + resetAt: now + retryAfterMs, + }, + }; +} + +/** + * Seconds after which a bucket for `config` is indistinguishable from an + * untouched one (it has refilled to capacity), plus one second of slack. + * + * This is the TTL a shared store should give a bucket entry: past it, dropping + * the row and re-creating it full is not an approximation, it is the same + * answer — which is what keeps a per-principal keyspace from growing without + * bound in Redis. + */ +export function bucketIdleTtlSeconds(config: RateLimitBucketConfig): number { + return Math.max(1, Math.ceil(config.capacity / config.refillPerSec) + 1); +} + /** * Storage interface — swap for Redis/Memcached in clustered deploys. * Implementations MUST be safe under concurrent access. @@ -105,41 +206,9 @@ export class RateLimiter { */ consume(key: string, cost = this.config.defaultCost ?? 1): RateLimitDecision { const now = this.now(); - const { capacity, refillPerSec } = this.config; - - let state = this.store.get(key); - if (!state) { - state = { tokens: capacity, lastRefill: now }; - } else { - const elapsedSec = (now - state.lastRefill) / 1000; - if (elapsedSec > 0) { - state = { - tokens: Math.min(capacity, state.tokens + elapsedSec * refillPerSec), - lastRefill: now, - }; - } - } - - if (state.tokens >= cost) { - state.tokens -= cost; - this.store.set(key, state); - return { - allowed: true, - remaining: Math.floor(state.tokens), - retryAfterMs: 0, - resetAt: now + Math.ceil(((capacity - state.tokens) / refillPerSec) * 1000), - }; - } - - const tokensNeeded = cost - state.tokens; - const retryAfterMs = Math.ceil((tokensNeeded / refillPerSec) * 1000); + const { state, decision } = applyTokenBucket(this.store.get(key), this.config, now, cost); this.store.set(key, state); - return { - allowed: false, - remaining: Math.floor(state.tokens), - retryAfterMs, - resetAt: now + retryAfterMs, - }; + return decision; } /** Force-reset a key (e.g. after a successful auth flow). */ @@ -149,19 +218,22 @@ export class RateLimiter { } /** - * Curated default buckets for the three traffic classes ObjectStack - * dispatches. Conservative — tune via `DispatcherPluginConfig.rateLimit` - * for your deployment. + * Reference budgets for three traffic classes, as ORDERS OF MAGNITUDE to size + * an authored `server.security.rateLimit` against — not a live configuration. * - * - auth: 10 req / minute / IP — guards /auth/* against credential - * stuffing and password-spray. - * - write: 60 req / minute / IP — POST/PUT/PATCH/DELETE. - * - read: 600 req / minute / IP — GET, including discovery and - * metadata. + * - auth: 10 req / minute — the scale that inconveniences credential-stuffing + * and password-spray without troubling a human logging in. + * - write: 60 req / minute — POST/PUT/PATCH/DELETE. + * - read: 600 req / minute — GET, including discovery and metadata. * - * "Per-IP" is just the suggested key shape; the dispatcher constructs - * the key from `${ip}:${bucket}` so a single noisy IP can saturate - * one bucket without blocking the others. + * ⚠️ **Nothing in the repo reads this constant.** It is exported reference + * material, and this comment says so in the tense that matches (#4937: the + * previous version described per-class dispatcher buckets keyed `${ip}:${bucket}` + * as if they existed — they never did, and the seam #4910 built is a SINGLE + * bucket per caller, not one per traffic class). Per-class budgets are a real + * and reasonable want; when one is implemented it should arrive with its + * executor and its authoring key, at which point this constant either becomes + * that feature's default or is deleted. */ export interface RateLimitDefaults { auth: RateLimitBucketConfig; diff --git a/packages/runtime/src/security/resolve-session-principal.ts b/packages/runtime/src/security/resolve-session-principal.ts new file mode 100644 index 0000000000..f3c58e8166 --- /dev/null +++ b/packages/runtime/src/security/resolve-session-principal.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * "Who is calling?", asked of the auth service from OUTSIDE a dispatch. + * + * Two consumers need this and they run in different kernel phases, which is why + * it is a module rather than a closure: `dispatcher-plugin`'s concrete route + * mounts build a slim `req.user` from it in `start()`, and the inbound rate + * limiter (#4910) needs only the principal id, in `init()`, to key a bucket + * before any route runs. One lookup, one set of defensive rules, so the two + * cannot drift on what counts as "authenticated". + * + * Every failure mode here resolves to `undefined` — meaning ANONYMOUS, not + * "error". Both callers treat anonymous as a legitimate state (the route's own + * auth gate still runs; the limiter keys by IP instead), so throwing would turn + * an auth hiccup into an outage on surfaces that never needed auth to begin + * with. + */ + +import type { IAuthService } from '@objectstack/spec/contracts'; + +/** Headers as adapters deliver them, or an already-built `Headers`. */ +export type HeaderBag = Record | Headers; + +function toHeaders(headers: HeaderBag): Headers { + if (headers instanceof Headers) return headers; + const out = new Headers(); + for (const key of Object.keys(headers)) { + const value = (headers as Record)[key]; + if (value == null) continue; + out.set(String(key), Array.isArray(value) ? value.join(',') : String(value)); + } + return out; +} + +/** + * The better-auth session payload for a request, or `undefined` when there is + * no auth service, no session API, or no session. + */ +export async function resolveSessionData( + authService: IAuthService | undefined, + headers: HeaderBag, +): Promise { + try { + if (!authService) return undefined; + let api: any = (authService as any).api; + if (!api && typeof (authService as any).getApi === 'function') { + api = await (authService as any).getApi(); + } + if (!api?.getSession) return undefined; + return await api.getSession({ headers: toHeaders(headers) }); + } catch { + return undefined; + } +} + +/** + * The calling principal's user id, or `undefined` for anonymous traffic. + */ +export async function resolveSessionPrincipalId( + authService: IAuthService | undefined, + headers: HeaderBag, +): Promise { + const sessionData = await resolveSessionData(authService, headers); + const userId: unknown = sessionData?.user?.id ?? sessionData?.session?.userId; + return typeof userId === 'string' && userId.length > 0 ? userId : undefined; +} diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3217d7fab0..f7384b5ec8 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1208,6 +1208,8 @@ "ServerEvent (type)", "ServerEventSchema (const)", "ServerEventType (type)", + "ServerRateLimitConfig (type)", + "ServerRateLimitConfigSchema (const)", "ServerStatus (type)", "ServerStatusSchema (const)", "ServiceConfigSchema (const)", @@ -1254,6 +1256,11 @@ "SpecifierScope (type)", "SpecifierScopeSchema (const)", "SpecifierType (type)", + "StackServerConfig (type)", + "StackServerConfigInput (type)", + "StackServerConfigSchema (const)", + "StackServerSecurity (type)", + "StackServerSecuritySchema (const)", "StorageAcl (type)", "StorageAclSchema (const)", "StorageClass (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 902015d532..d88c4db1dd 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -6547,6 +6547,9 @@ "system/ServerEvent:data", "system/ServerEvent:timestamp", "system/ServerEvent:type", + "system/ServerRateLimitConfig:enabled", + "system/ServerRateLimitConfig:maxRequests", + "system/ServerRateLimitConfig:windowMs", "system/ServerStatus:connections", "system/ServerStatus:requests", "system/ServerStatus:server", @@ -6654,6 +6657,9 @@ "system/SpecifierOption:icon", "system/SpecifierOption:label", "system/SpecifierOption:value", + "system/StackServerConfig:security", + "system/StackServerConfig:trustProxy", + "system/StackServerSecurity:rateLimit", "system/StorageConnection:accessKeyId", "system/StorageConnection:accountKey", "system/StorageConnection:accountName", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 88c49721ab..43cd34e443 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1388,6 +1388,7 @@ "system/ServerCapabilities", "system/ServerEvent", "system/ServerEventType", + "system/ServerRateLimitConfig", "system/ServerStatus", "system/ServiceConfig", "system/ServiceCriticality", @@ -1412,6 +1413,8 @@ "system/SpecifierOption", "system/SpecifierScope", "system/SpecifierType", + "system/StackServerConfig", + "system/StackServerSecurity", "system/StorageAcl", "system/StorageClass", "system/StorageConnection", diff --git a/packages/spec/src/contracts/http-server.ts b/packages/spec/src/contracts/http-server.ts index 35b4ab1a2b..9985ae221c 100644 --- a/packages/spec/src/contracts/http-server.ts +++ b/packages/spec/src/contracts/http-server.ts @@ -35,6 +35,22 @@ export interface IHttpRequest { * undefined when the underlying framework cannot expose the raw stream. */ rawBody?: () => Promise; + + /** + * The TRANSPORT's own peer address for this request — the socket's remote + * address, never a header. + * + * CONTRACT (#4910): this member is the unforgeable half of caller + * identification. `X-Forwarded-For` and friends live in {@link headers} and + * are believable only when the deployment declares `server.trustProxy`; + * this one a client cannot influence, which is why the inbound rate limiter + * keys anonymous traffic off it by default. + * + * Optional because not every runtime exposes it (an edge/Workers host may + * have no socket at all). Consumers MUST degrade deliberately when it is + * absent — never substitute a header for it silently, and never fall open. + */ + remoteAddress?: string; } /** @@ -95,7 +111,25 @@ export type RouteHandler = ( ) => void | Promise; /** - * Middleware function + * Middleware function. + * + * ## Contract (#4910) + * + * A middleware either **continues** the chain by calling `next()`, or + * **short-circuits** it by writing a response (`res.status(…).json(…)` / + * `.send(…)`) and NOT calling `next()`. Doing neither is pass-through, so a + * forgotten branch cannot black-hole a request. Implementations MUST honour the + * short-circuit: the whole point of the seam is that a gate — rate limiting, + * maintenance mode — can answer instead of the route. + * + * Two limits every implementation shares, stated here so consumers do not + * discover them per adapter: + * + * - `req.body` is NOT populated. Parsing it here would consume the stream + * before the route handler that owns it. + * - Middleware must be registered BEFORE the routes it should guard. Register + * in a plugin's `init()` (kernel Phase 1); every route is mounted in some + * plugin's `start()` (Phase 2), so this ordering is automatic. */ export type Middleware = ( req: IHttpRequest, diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 9f9270631a..a275829747 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -7,6 +7,7 @@ import { validateObjectNamespacePrefix } from './kernel/namespace-prefix'; import { PLATFORM_CAPABILITY_TOKENS } from './kernel/platform-capabilities'; import { DatasourceSchema } from './data/datasource.zod'; import { TranslationBundleSchema, TranslationConfigSchema } from './system/translation.zod'; +import { StackServerConfigSchema } from './system/stack-server.zod'; import { hasPlatformObjectPrefix } from './system/constants/platform-object-names'; import { objectStackErrorMap, formatZodError } from './shared/error-map.zod'; import { normalizeStackInput, type MetadataCollectionInput, type MapSupportedField } from './shared/metadata-collection.zod'; @@ -294,6 +295,21 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ enforceProjectMembership: z.boolean().optional(), }).optional().describe('Server-facing API config consumed by objectstack serve/dev'), + /** + * Server-level runtime configuration read by `objectstack serve` / `dev`. + * + * DELIBERATELY NARROW (#4910): it carries only keys an executor consumes — + * today `security.rateLimit` (the inbound token bucket that answers 429) and + * `trustProxy` (how that limiter identifies a caller). It is NOT the nine-key + * `HttpServerConfigSchema`: seven of those keys have no reader and no + * authoring surface, and mounting them here would make dead keys writable + * (their enforce-or-remove fate is #4938). Port/host stay a deployment + * concern owned by `objectstack serve -p`; see the schema file for the + * precedence rule and the rest of the rationale. + */ + server: StackServerConfigSchema.optional() + .describe('Server-level runtime config consumed by objectstack serve/dev (inbound rate limit, proxy trust)'), + /** * ObjectAI: Artificial Intelligence Layer * diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index c0ac38762e..8c4dd42c30 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -17,6 +17,7 @@ export * from './message-queue.zod'; export * from './object-storage.zod'; export * from './search-engine.zod'; export * from './http-server.zod'; +export * from './stack-server.zod'; // Observability & Operations // audit.zod (AuditConfig/AuditStorageConfig/AuditRetentionPolicy/AuditEventFilter/ diff --git a/packages/spec/src/system/stack-server.test.ts b/packages/spec/src/system/stack-server.test.ts new file mode 100644 index 0000000000..86f78c5105 --- /dev/null +++ b/packages/spec/src/system/stack-server.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `server:` — the authorable half of the inbound rate-limit seam (#4910). + * + * These assertions are about the AUTHORING contract only: what a stack may + * write, what it may not, and what the parse hands the runtime. That the + * declaration then produces a 429 is proven where it happens — + * `packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts` + * boots a real server and drives it over a socket. Two halves, deliberately: + * this issue exists because a schema and an executor were each tested alone + * and nobody tested that they were connected. + */ + +import { describe, it, expect } from 'vitest'; + +import { defineStack } from '../stack.zod'; +import { + StackServerConfigSchema, + ServerRateLimitConfigSchema, +} from './stack-server.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; + +const manifest = { + id: 'com.example.rate-limit', + name: 'Rate limit fixture', + version: '1.0.0', + type: 'app' as const, +}; + +describe('server: reaches the runtime through defineStack', () => { + it('survives the strict stack parse instead of being stripped', () => { + const stack = defineStack({ + manifest, + server: { + security: { rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 5 } }, + trustProxy: true, + }, + } as never); + + // The whole point of declaring the key: before #4910 `server:` was an + // undeclared key, so `defineStack` dropped it silently and the CLI had + // nothing to read. + expect((stack as Record).server).toEqual({ + security: { rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 5 } }, + trustProxy: true, + }); + }); + + it('is optional — a stack that declares nothing gets nothing', () => { + const stack = defineStack({ manifest } as never); + expect((stack as Record).server).toBeUndefined(); + }); + + it('is declared on the stack schema itself (so the unknown-key lint stays quiet)', () => { + expect(Object.keys((ObjectStackDefinitionSchema as never as { shape: object }).shape)) + .toContain('server'); + }); +}); + +describe('server: carries only keys with a consumer (#4938 stays shut)', () => { + it('declares exactly `security` and `trustProxy`', () => { + // The hard constraint from the 2026-08-03 adjudication. If this list grows, + // the new key must have arrived with an executor — the whole reason the + // nine-key HttpServerConfigSchema was NOT mounted here. + expect(Object.keys((StackServerConfigSchema as never as { shape: object }).shape).sort()) + .toEqual(['security', 'trustProxy']); + }); + + it.each([ + ['port', /objectstack serve -p/], + ['host', /belongs to the deployment/], + ['compression', /#4938/], + ['requestTimeout', /#4938/], + ['bodyLimit', /#4938/], + ['static', /transport plugin/], + ['cors', /OS_CORS_ORIGIN/], + ])('rejects the unconsumed HttpServerConfig key `%s` with a prescription', (key, expected) => { + const result = StackServerConfigSchema.safeParse({ [key]: 1 }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + expect(message).toMatch(/Unrecognized key/); + expect(message).toMatch(expected); + }); +}); + +describe('server.security.rateLimit is strict from birth (#4001)', () => { + it('accepts every declared key', () => { + expect(ServerRateLimitConfigSchema.parse({ enabled: true, windowMs: 1000, maxRequests: 3 })) + .toEqual({ enabled: true, windowMs: 1000, maxRequests: 3 }); + }); + + it('applies the shared defaults when only `enabled` is written', () => { + expect(ServerRateLimitConfigSchema.parse({ enabled: true })) + .toEqual({ enabled: true, windowMs: 60_000, maxRequests: 100 }); + }); + + it('rejects a near-miss and names the key it meant', () => { + const result = ServerRateLimitConfigSchema.safeParse({ enabled: true, max: 5 }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toMatch(/`max` → `maxRequests`/); + }); + + it('rejects a budget that can never admit a request', () => { + // `.int()` alone accepts 0 — and a zero-capacity bucket denies everything, + // including health checks. Caught at AUTHORING, where the fix is one edit + // away, rather than at boot or (worse) in production traffic. + const result = ServerRateLimitConfigSchema.safeParse({ enabled: true, maxRequests: 0 }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.path).toEqual(['maxRequests']); + expect(result.error!.issues[0]!.message).toMatch(/set `enabled: false`/); + }); + + it('rejects a zero window', () => { + const result = ServerRateLimitConfigSchema.safeParse({ enabled: true, windowMs: 0 }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.path).toEqual(['windowMs']); + expect(result.error!.issues[0]!.message).toMatch(/MILLISECONDS/); + }); +}); + +describe('server.trustProxy defaults to not believing the caller', () => { + it('is false when unwritten', () => { + expect(StackServerConfigSchema.parse({})).toEqual({ trustProxy: false }); + }); + + it('describes what declaring it means, so the security choice is reviewable', () => { + const description = (StackServerConfigSchema as never as { + shape: Record; + }).shape.trustProxy.description ?? ''; + expect(description).toMatch(/X-Forwarded-For/); + expect(description).toMatch(/reverse proxy you control/); + }); + + it('documents the key shape on the rateLimit describe (Q3, reviewable in the schema)', () => { + const description = (StackServerConfigSchema as never as { + shape: Record } }>; + }).shape.security.unwrap().shape.rateLimit.description ?? ''; + expect(description).toMatch(/RESOLVED PRINCIPAL/); + expect(description).toMatch(/falling back to the caller IP/); + expect(description).toMatch(/Retry-After/); + }); +}); diff --git a/packages/spec/src/system/stack-server.zod.ts b/packages/spec/src/system/stack-server.zod.ts new file mode 100644 index 0000000000..73942dc166 --- /dev/null +++ b/packages/spec/src/system/stack-server.zod.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defineStack({ server })` — the authorable server-facing configuration. + * + * ## Why this is NOT `HttpServerConfigSchema` + * + * `system/http-server.zod.ts` declares nine keys (`port`, `host`, `cors`, + * `requestTimeout`, `bodyLimit`, `compression`, `security`, `static`, + * `trustProxy`). #4938 measured them: **none had a runtime reader and none was + * reachable from any authoring surface** — `stack.zod.ts` had no `server:` key, + * so the whole shape was unwritable as well as unread. Mounting it wholesale + * here would have made eight dead keys authorable in one move, which is the + * declared-≠-enforced defect (Prime Directive #10) manufactured on purpose. + * + * So this schema is deliberately NARROW: it carries only keys an executor + * actually consumes, and it grows one key at a time, each arriving with its + * consumer. Today that is exactly two: + * + * | key | consumed by | + * |---|---| + * | `security.rateLimit` | `createDispatcherPlugin` → the inbound token bucket (`@objectstack/runtime` `security/inbound-rate-limit.ts`) — an over-budget caller gets `429` + `Retry-After` | + * | `trustProxy` | the same limiter's IP resolution — see below | + * + * The other seven `HttpServerConfigSchema` keys stay unreachable, and their + * enforce-or-remove fate is tracked by #4938. Adding one here without an + * executor re-opens the hole this narrowness exists to close. + * + * ## What `server:` is NOT for + * + * **Deployment knobs stay on the CLI.** There is no `server.port` / `server.host` + * on purpose: the listening socket is a property of *where* a stack runs, not of + * the stack itself, and it is already owned by `objectstack serve -p ` / + * `PORT`. Two authorities for one number is how a config becomes advisory. If a + * future need does add `server.port`, the precedence is settled in advance and + * recorded here so it cannot be re-litigated per-caller: **the CLI flag wins over + * `server:`, and `server:` wins over the built-in default** — an operator + * overriding a port at the command line must not be silently overruled by a file + * baked into the artifact. + * + * Related: #4910 (this seam), #4937 (the limiter that documented an execution + * chain it never had), #4936 (`apis:` endpoint-level `rateLimit`, still + * unwired), ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). + */ + +import { z } from 'zod'; + +import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; +import { RateLimitConfigSchema } from '../shared/http.zod'; + +/** + * `server.security.rateLimit` — the shared {@link RateLimitConfigSchema} shape, + * closed against unknown keys for this authoring surface. + * + * The SHAPE is reused verbatim (`RateLimitConfigSchema.shape`) rather than + * retyped, so there is no fourth rate-limit shape in the repo and no drift to + * police — #4686 opened on there already being three. What is added is + * strictness: this key is new, so it joins the #4001 ratchet at birth instead of + * being tightened later, and a misspelled budget (`maxRequest`, `window`) is + * rejected at parse rather than silently defaulted to 100 req/min. + */ +export const ServerRateLimitConfigSchema = lazySchema(() => strictObject( + { + surface: 'server.security.rateLimit', + history: + 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.', + aliases: { + window: 'windowMs', + windowSeconds: 'windowMs', + max: 'maxRequests', + maxRequest: 'maxRequests', + limit: 'maxRequests', + }, + guidance: { + keyBy: + 'The rate-limit key is not authorable. It is the resolved principal, falling back to the caller IP for ' + + 'anonymous traffic; whether the IP is read from forwarded headers is decided by `server.trustProxy`.', + store: + 'The counter store is not authorable. Counters live in the kernel `cache` service when one is registered ' + + '(ADR-0069 D2) and degrade to a per-process store otherwise, announced once at boot.', + }, + }, + RateLimitConfigSchema.shape, +).superRefine((value, ctx) => { + // The shared shape declares `.int()` but no lower bound, so `0` and negatives + // parse. They are not budgets: `maxRequests: 0` rejects every request + // including your own health checks, and either zero makes the derived refill + // rate undefined (`maxRequests / (windowMs / 1000)`). + // + // Rejecting HERE rather than at boot is the point (#4910 axis 2): an AI- + // authored stack finds out at `defineStack`, in the file it is writing, with + // the fix in the message — not from a 429 storm, and not from a boot log + // nobody reads. The runtime derivation keeps its own guard for callers that + // build a budget programmatically; this is the one authors hit. + if (value.maxRequests <= 0) { + ctx.addIssue({ + code: 'custom', + path: ['maxRequests'], + message: + `maxRequests must be greater than 0 (got ${value.maxRequests}). A zero budget rejects every request; ` + + 'to turn rate limiting off set `enabled: false`.', + }); + } + if (value.windowMs <= 0) { + ctx.addIssue({ + code: 'custom', + path: ['windowMs'], + message: + `windowMs must be greater than 0 (got ${value.windowMs}). It is the budget window in MILLISECONDS — ` + + '60000 is one minute.', + }); + } +})); + +export type ServerRateLimitConfig = z.infer; + +/** + * `server.security` — security configuration consumed by the inbound seam. + */ +export const StackServerSecuritySchema = lazySchema(() => strictObject( + { + surface: 'server.security', + history: + 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.', + guidance: { + helmet: + 'Not authorable here. Response hardening headers are configured on the dispatcher plugin ' + + '(`securityHeaders`), which is on by default — see `buildSecurityHeaders` in @objectstack/runtime.', + cors: + 'Not authorable here. CORS is owned by the transport adapter and configured by ' + + 'OS_CORS_ORIGIN / OS_CORS_CREDENTIALS / OS_CORS_MAX_AGE.', + }, + }, + { + /** + * Global inbound rate limit. `enabled: false` (the default) leaves every + * request unmetered; `enabled: true` arms a token bucket in front of every + * route this server mounts. + */ + rateLimit: ServerRateLimitConfigSchema.optional().describe( + 'Global inbound rate limit. When `enabled`, every inbound request consumes from a token bucket derived from ' + + 'this budget (capacity = `maxRequests`, refill = `maxRequests / (windowMs / 1000)` tokens per second); an ' + + 'empty bucket answers 429 with a `Retry-After` header. The bucket is keyed by the RESOLVED PRINCIPAL, ' + + 'falling back to the caller IP for anonymous traffic — so one abusive session cannot exhaust another ' + + "user's budget, and credential-stuffing traffic (which has no principal yet) is still metered per source. " + + 'See `server.trustProxy` for how that IP is determined.', + ), + }, +)); + +export type StackServerSecurity = z.infer; + +/** + * The `server:` block of a stack definition. + */ +export const StackServerConfigSchema = lazySchema(() => strictObject( + { + surface: 'the stack `server` block', + history: + 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.', + guidance: { + port: + 'Not authorable. The listening port belongs to the deployment, not the stack — pass ' + + '`objectstack serve -p ` or set PORT.', + host: + 'Not authorable. The bind address belongs to the deployment, not the stack — pass it to ' + + '`objectstack serve`.', + cors: + 'Not authorable here. CORS is owned by the transport adapter and configured by ' + + 'OS_CORS_ORIGIN / OS_CORS_CREDENTIALS / OS_CORS_MAX_AGE.', + compression: 'Not authorable — no runtime reads it (#4938).', + requestTimeout: 'Not authorable — no runtime reads it (#4938).', + bodyLimit: 'Not authorable — no runtime reads it (#4938).', + static: 'Not authorable. Static mounts are configured on the transport plugin (`staticMounts`).', + }, + }, + { + security: StackServerSecuritySchema.optional().describe( + 'Server-level security configuration. Today: the global inbound rate limit.', + ), + + /** + * Whether an `X-Forwarded-For` / `X-Real-IP` header may be believed. + * + * This is a SECURITY declaration, not a convenience toggle. Anything a + * client can send, a client can forge: an attacker who can pick their own + * `X-Forwarded-For` gets an unlimited supply of fresh rate-limit buckets + * (limit bypassed) AND can spend another caller's budget by claiming their + * address (limit weaponised). Trusting the header therefore has to be an act + * of authorship — the operator asserting "a proxy I control rewrites this + * header on every request" — and never a default. + */ + trustProxy: z.boolean().default(false).describe( + 'Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy ' + + 'you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is ' + + "the transport's own peer address, which a client cannot forge. Consumed by the inbound rate limiter when " + + '`server.security.rateLimit.enabled` is set.', + ), + }, +)); + +export type StackServerConfig = z.infer; +export type StackServerConfigInput = z.input;