diff --git a/.changeset/25058-trustproxy-three-consumers.md b/.changeset/25058-trustproxy-three-consumers.md new file mode 100644 index 00000000000..1e7c2890ccd --- /dev/null +++ b/.changeset/25058-trustproxy-three-consumers.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): `server.trustProxy`'s description names all three consumers of the client-address rule + +Clause-②: no (prose only: no shape, key, default or accepted value moves) + +The `.describe()` on `server.trustProxy` said the client-address rule had two consumers: the inbound rate limiter and the authentication plugin. There is a third. A declared endpoint (`apis:`) whose own `rateLimit` is enabled is metered by the endpoint policy chain, which keys anonymous callers by the same rule under the same `trustProxy`, whether or not `server.security.rateLimit.enabled` is set. The description and the TSDoc now name it. No key, type, default or accepted value changes. diff --git a/content/docs/references/system/stack-server.mdx b/content/docs/references/system/stack-server.mdx index 57756024dc6..d9756b0e458 100644 --- a/content/docs/references/system/stack-server.mdx +++ b/content/docs/references/system/stack-server.mdx @@ -101,7 +101,7 @@ const result = ServerRateLimitConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **security** | `{ rateLimit?: object }` | optional | Server-level security configuration. Today: the global inbound rate limit. | -| **trustProxy** | `boolean` | optional (default: `false`) | 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. One client-address rule, two consumers: the inbound rate limiter when `server.security.rateLimit.enabled` is set, and the authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges and whose login audit stamps (`last_login_ip`) the same address. | +| **trustProxy** | `boolean` | optional (default: `false`) | 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. One client-address rule, three consumers: the inbound rate limiter when `server.security.rateLimit.enabled` is set; the per-endpoint rate limiter of every declared endpoint (`apis:`) whose own `rateLimit` is enabled, whether or not the server-level one is; and the authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges and whose login audit stamps (`last_login_ip`) the same address. | ### Nested Shape: `StackServerConfig.security` diff --git a/packages/spec/src/system/stack-server.zod.ts b/packages/spec/src/system/stack-server.zod.ts index c82ac67c894..9975481ce7d 100644 --- a/packages/spec/src/system/stack-server.zod.ts +++ b/packages/spec/src/system/stack-server.zod.ts @@ -210,19 +210,24 @@ export const StackServerConfigSchema = lazySchema(() => strictObject( * of authorship — the operator asserting "a proxy I control rewrites this * header on every request" — and never a default. * - * Both consumers apply one rule, `resolveClientAddress` from + * Three consumers apply one rule, `resolveClientAddress` from * `@objectstack/plugin-auth/client-address`: the runtime's inbound rate - * limiter, and `AuthPlugin` (passed this value by `objectstack serve`), - * whose ADR-0069 D5 IP allow-list and D7 `last_login_ip` audit use the - * address it establishes — so a header believed without such a proxy is - * an allow-list bypass as well as a rate-limit one. + * limiter; the endpoint policy chain's per-endpoint rate limiter, for + * every declared endpoint whose own `rateLimit` is enabled, whether or not + * the server-level budget is; and `AuthPlugin` (passed this value by + * `objectstack serve`), whose ADR-0069 D5 IP allow-list and D7 + * `last_login_ip` audit use the address it establishes — so a header + * believed without such a proxy is an allow-list bypass as well as a + * rate-limit one. */ 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. One client-address rule, two consumers: the " - + 'inbound rate limiter when `server.security.rateLimit.enabled` is set, and the authentication plugin, whose IP ' - + 'allow-list (`allowed_ip_ranges`) judges and whose login audit stamps (`last_login_ip`) the same address.', + + "the transport's own peer address, which a client cannot forge. One client-address rule, three consumers: the " + + 'inbound rate limiter when `server.security.rateLimit.enabled` is set; the per-endpoint rate limiter of every ' + + 'declared endpoint (`apis:`) whose own `rateLimit` is enabled, whether or not the server-level one is; and the ' + + 'authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges and whose login audit stamps ' + + '(`last_login_ip`) the same address.', ), }, ));