Skip to content

Commit 55d7aab

Browse files
docs(spec,adr-0069): server.trustProxy names both client-address consumers; D5 mechanism amended (#24859)
Closes #24818 Clause-②: no Two texts were behind the code after PR #24795 (`25e16ab00c`). This PR fixes both. No code, schema shape, key, default or accepted value changes. ## What changed 1. **`packages/spec/src/system/stack-server.zod.ts`, the `server.trustProxy` `.describe()` and TSDoc.** The describe named only the inbound rate limiter. It now names both consumers of the one client-address rule: the inbound rate limiter (when `server.security.rateLimit.enabled` is set), and the authentication plugin. That plugin's IP allow-list (`allowed_ip_ranges`) judges the address the rule establishes, and its login audit stamps it as `last_login_ip`. The TSDoc names the shared function (`resolveClientAddress` from `@objectstack/plugin-auth/client-address`) and says that a header believed without a rewriting proxy bypasses the allow-list as well as the rate limit. 2. **`content/docs/references/system/stack-server.mdx`**, regenerated with `pnpm --filter @objectstack/spec gen:docs`. `check:generated` named this as the only stale artifact. The diff is the one table row. 3. **`docs/adr/0069-enterprise-authentication-hardening.md`, D5.** I added a dated amendment note under the D5 table. It follows the in-file precedent: the D3 note "What the gate holds (amended 2026-10-14 ...)", plus the matching `· **Amended** (...)` entry on the Status line. The original mechanism cell is unchanged, apart from an appended pointer, *(client-address mechanism amended 2026-10-20 — see the note below)*. The note records: - `X-Forwarded-For` (leftmost entry), then `X-Real-IP`, are believed only under `server.trustProxy`. Otherwise the address is the transport's peer address. - `CF-Connecting-IP` is no longer read. - While any range is set, an address that cannot be established is refused with `403 IP_NOT_ALLOWED`. With no ranges set, nothing is judged. - The D7 audit stamps the same address and stamps none when it is undetermined. - `objectstack serve` passes `server.trustProxy` to `AuthPlugin`. 4. **`.changeset/24818-trustproxy-consumers-describe.md`**, a `patch` for `@objectstack/spec`, because the describe string ships in the spec's published `dist`. ## Each claim checked against the code at `origin/main` `41646a5788` - The rule: `packages/plugins/plugin-auth/src/client-address.ts` `resolveClientAddress` reads `x-forwarded-for`, then `x-real-ip`, only when `input.trustProxy` is true. Otherwise it uses `remoteAddress`. With neither available it returns `undefined`. - The rule is published as a subpath (`packages/plugins/plugin-auth/package.json`, `exports["./client-address"]`, a tsup entry) and imported back by `packages/runtime/src/security/inbound-rate-limit.ts:40`. - `serve` wiring: `packages/cli/src/commands/serve.ts:4263` passes `trustProxy: (config as any).server?.trustProxy === true` to `new AuthPlugin(...)`. - The allow-list judges the established address: `packages/plugins/plugin-auth/src/auth-plugin.ts`, in the D5 middleware, calls `mgr.resolveClientAddress(...)`. `AuthManager.resolveClientAddress` applies the shared rule with `this.config.trustProxy`. - Undetermined means refused: `AuthManager.isClientIpAllowed` returns true when no ranges are set, and `if (!ip) return false` otherwise. The middleware then answers `403` with `error.code: 'IP_NOT_ALLOWED'`. - `CF-Connecting-IP`: `git grep -i cf-connecting-ip -- packages` finds only `CHANGELOG.md` entries and no source. - D7: the sign-in after-hook calls `stampLastLogin(uid, await readRequestClientAddress())`. `stampLastLogin` writes `last_login_ip` only `if (ip)`. ## Governed surface `docs/adr/**` is a governed surface at **Tier H**. This PR lands only after an authorized approval by an account in `GOVERNED_APPROVERS`. The seat handles that approval. ## Verification (at `d4393185dd`) - `pnpm --filter @objectstack/spec build` passed (`VERDICT command-exit 0`). - `pnpm --filter @objectstack/spec check:generated`: before regenerating, it reported "1 of 14 artifact(s) stale: content/docs/references/**". After `gen:docs` it reported "✓ All 14 generated artifacts are up to date". - `pnpm --filter @objectstack/spec typecheck` passed (`VERDICT command-exit 0`). - Spec tests, narrowed: `vitest run src/system/stack-server.test.ts src/system/http-server.test.ts src/compose-stacks-key-loss.test.ts scripts/build-schemas-check-mode.test.ts` gave `Test Files 4 passed (4)`, `Tests 154 passed (154)`. These are the spec tests that name `stack-server` or `trustProxy`. The full spec suite did not finish inside the container's foreground cap on a shared box, so it is left to CI's `Test Core`. No test pins the old describe text: `git grep "Consumed by the inbound"` has no hit outside the changed source. - `node scripts/pm/dispatch-gates.mjs --ran` gave "111 derived famil(ies) accounted for — 109 run, 2 NOT-MEASURED". Every command that ran exited 0, after building the `@objectstack/lint...` and `@objectstack/client-react...` closures so four of them could measure. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: needs a whole-workspace `dist` (exit 3, prerequisite not met). Left to CI. - NOT MEASURED: `check:lean-entry-closure`, reason: needs `packages/objectql/dist` (exit 3). Left to CI. - `pnpm check:nul-bytes`: OK. ## Acceptance notes - None. No out-of-scope findings. ## 维护者速读(草稿) - **改了什么**:只改文字,不改代码。`server.trustProxy` 的说明原来只写了"限流器读它";现在写明认证插件的 IP 白名单和登录审计(`last_login_ip`)也按同一条规则取客户端地址。ADR-0069 D5 补一条带日期的修订注记,写明现在的取址方式。 - **为什么改**:PR #24795 之后代码已经这样运行,但对外文档和 ADR 还是旧说法(还列着已不再读取的 `CF-Connecting-IP`)。运维照旧文档配置,可能不知道这个开关同时影响登录白名单。 - **风险与代价(含回滚)**:无运行时行为变化,风险极低。回滚即 revert 本 PR。 - **席位意见**: - **你要做的**:ADR 属 Tier H 受管面,需要你批准后才能落地。 --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 157ebd0 commit 55d7aab

4 files changed

Lines changed: 35 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): `server.trustProxy`'s description names both consumers of the client-address rule
6+
7+
Clause-②: no
8+
9+
The `.describe()` on `server.trustProxy` named only the inbound rate limiter. `objectstack serve` also passes the value to the authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges, and whose login audit stamps as `last_login_ip`, the address that the same rule establishes (`@objectstack/plugin-auth/client-address`). The description now names both. No key, type, default or accepted value changes.

‎content/docs/references/system/stack-server.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const result = ServerRateLimitConfigSchema.parse(data);
101101
| Property | Type | Required | Description |
102102
| :--- | :--- | :--- | :--- |
103103
| **security** | `{ rateLimit?: object }` | optional | Server-level security configuration. Today: the global inbound rate limit. |
104-
| **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. Consumed by the inbound rate limiter when `server.security.rateLimit.enabled` is set. |
104+
| **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. |
105105

106106
### Nested Shape: `StackServerConfig.security`
107107

‎docs/adr/0069-enterprise-authentication-hardening.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0069: Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, and anti-brute-force, all enforcement-wired
22

3-
**Status**: Accepted — **P1 + P2 implemented** (2026-07-04); P3 partially landed (see Addendum). Original proposal 2026-06-24. Session-of-record question settled 2026-08-04 (#4785): sessions live in `sys_session`; the kernel `cache` service backs D2's rate-limit counters only. · **Amended** (2026-10-14, ruling C′ on [#23287](https://github.com/objectstack-ai/objectstack/issues/23287), [`6159450584`](https://github.com/objectstack-ai/objectstack/issues/23287#issuecomment-6159450584), maintainer 「同意」) — D3 states what the gate holds for an API key, for password expiry and for a forced reset; see the note under D3.
3+
**Status**: Accepted — **P1 + P2 implemented** (2026-07-04); P3 partially landed (see Addendum). Original proposal 2026-06-24. Session-of-record question settled 2026-08-04 (#4785): sessions live in `sys_session`; the kernel `cache` service backs D2's rate-limit counters only. · **Amended** (2026-10-14, ruling C′ on [#23287](https://github.com/objectstack-ai/objectstack/issues/23287), [`6159450584`](https://github.com/objectstack-ai/objectstack/issues/23287#issuecomment-6159450584), maintainer 「同意」) — D3 states what the gate holds for an API key, for password expiry and for a forced reset; see the note under D3. · **Amended** (2026-10-20, PR [#24795](https://github.com/objectstack-ai/objectstack/pull/24795), no decision changes) — D5's client-address mechanism follows the shared `server.trustProxy` rule; see the note under D5.
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (**the governing constraint** — a security property that isn't enforced at runtime is forbidden; no toggle may be a "false surface"), [ADR-0007](./0007-settings-manifest-and-kv-store.md) (settings manifest + cascade KV store), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native, decoupled from better-auth; org scoping), [ADR-0066](./0066-unified-authorization-model.md) (capability/assignment split), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (`current_user` contract, built-in roles)
66
**Consumers**: `@objectstack/plugin-auth` (better-auth wiring, `bindAuthSettings`/`applyConfigPatch`, auth route middleware), `@objectstack/service-settings` (`auth.manifest.ts`), `@objectstack/platform-objects` (identity objects `sys_user`/`sys_session`/`sys_account`), `@objectstack/rest` (auth request middleware seam), `../objectui` (settings UI rendering)
@@ -132,11 +132,24 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom
132132

133133
| Setting | Default | Seam | Mechanism |
134134
|---|---|---|---|
135-
| `sys_organization.allowed_ip_ranges` (CIDR[]) | — | auth request middleware (Hono, **before** the better-auth handler in `plugin-auth` route registration) | **[custom]** extract client IP (`x-forwarded-for` / `cf-connecting-ip`, trust-proxy aware); reject login/session outside the org's ranges with `IP_NOT_ALLOWED` |
135+
| `sys_organization.allowed_ip_ranges` (CIDR[]) | — | auth request middleware (Hono, **before** the better-auth handler in `plugin-auth` route registration) | **[custom]** extract client IP (`x-forwarded-for` / `cf-connecting-ip`, trust-proxy aware); reject login/session outside the org's ranges with `IP_NOT_ALLOWED` *(client-address mechanism amended 2026-10-20 — see the note below)* |
136136
| `sys_user.allowed_ip_ranges` (optional override) | — | same | **[custom]** per-user override, evaluated before org |
137137
| `require_ip_match` (toggle) | off | session refresh | **[custom]** detect `request IP != session.ip_address` (already captured) -> re-verify or revoke |
138138

139139
> IP is already captured on `sys_session.ip_address`; the only missing piece is the **gate** — a middleware seam exists in the auth route registration, before requests reach better-auth.
140+
>
141+
> **How the client address is established (amended 2026-10-20: PR [#24795](https://github.com/objectstack-ai/objectstack/pull/24795),
142+
> `25e16ab00c`, for [#24739](https://github.com/objectstack-ai/objectstack/issues/24739)).** The header list in the
143+
> first row's mechanism cell no longer describes the code. The address the allow-list judges is the one the server
144+
> establishes by the platform's single client-address rule, `resolveClientAddress`, published as
145+
> `@objectstack/plugin-auth/client-address` and imported back by the runtime's inbound rate limiter, so both apply it
146+
> identically: `X-Forwarded-For` (its leftmost entry), then `X-Real-IP`, are believed **only** when the deployment
147+
> declares `server.trustProxy` (`objectstack serve` passes the authored value to `AuthPlugin`); otherwise the address is
148+
> the transport's own peer address. `CF-Connecting-IP` is no longer read. An address that cannot be established is
149+
> refused like one outside the ranges — `403 IP_NOT_ALLOWED` while any range is set (with no ranges set, nothing is
150+
> judged). The D7 login audit stamps that same established address as `last_login_ip`, and stamps none when it is
151+
> undetermined. The decision itself — a gate ahead of the better-auth handler, refusing with `IP_NOT_ALLOWED` — is
152+
> unchanged.
140153
141154
### D6 — Enterprise SSO (P2/P3)
142155

‎packages/spec/src/system/stack-server.zod.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,20 @@ export const StackServerConfigSchema = lazySchema(() => strictObject(
209209
* address (limit weaponised). Trusting the header therefore has to be an act
210210
* of authorship — the operator asserting "a proxy I control rewrites this
211211
* header on every request" — and never a default.
212+
*
213+
* Both consumers apply one rule, `resolveClientAddress` from
214+
* `@objectstack/plugin-auth/client-address`: the runtime's inbound rate
215+
* limiter, and `AuthPlugin` (passed this value by `objectstack serve`),
216+
* whose ADR-0069 D5 IP allow-list and D7 `last_login_ip` audit use the
217+
* address it establishes — so a header believed without such a proxy is
218+
* an allow-list bypass as well as a rate-limit one.
212219
*/
213220
trustProxy: z.boolean().default(false).describe(
214221
'Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy '
215222
+ 'you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is '
216-
+ "the transport's own peer address, which a client cannot forge. Consumed by the inbound rate limiter when "
217-
+ '`server.security.rateLimit.enabled` is set.',
223+
+ "the transport's own peer address, which a client cannot forge. One client-address rule, two consumers: the "
224+
+ 'inbound rate limiter when `server.security.rateLimit.enabled` is set, and the authentication plugin, whose IP '
225+
+ 'allow-list (`allowed_ip_ranges`) judges and whose login audit stamps (`last_login_ip`) the same address.',
218226
),
219227
},
220228
));

0 commit comments

Comments
 (0)