You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 25e16ab
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-auth): the auth IP allow-list judges the client address the server established (#24795)
Fixes#24739
Clause-②: no
## What changes
The ADR-0069 D5 IP allow-list (`allowed_ip_ranges`) and the D7 login
audit (`last_login_ip`) now establish a request's client address by the
platform's one client-address trust rule, the rule the runtime's inbound
rate limiter already used:
- `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when
the stack declares `server.trustProxy`.
- Otherwise the address is the transport's peer address
(`IHttpRequest.remoteAddress`).
- With ranges configured, an address that cannot be established is
refused with `403 IP_NOT_ALLOWED`. That is the code and status the gate
already uses for an address outside the ranges. The first such refusal
logs one operator warning that names `allowed_ip_ranges` and
`server.trustProxy`.
- With no ranges configured, nothing is judged. This is unchanged.
- The D7 audit stamps the address the gate judged, or no address when
none could be established.
The maintainer's direction, verbatim from the card: 「登录 IP 白名单信任可伪造的 IP
头,取不到 IP 时还放行」
## How
| File | Change |
|---|---|
| `packages/plugins/plugin-auth/src/client-address.ts` (new) |
`resolveClientAddress`: the address half of the runtime's
`resolveRateLimitKey`, moved unchanged. It is published as the
dependency-free `@objectstack/plugin-auth/client-address` subpath (679 B
ESM, no imports), beside `./rate-limit-storage`. |
| `packages/runtime/src/security/inbound-rate-limit.ts` |
`resolveRateLimitKey` imports the rule instead of keeping its own copy.
Its keys are unchanged, and the limiter's own pins are green (below). |
| `packages/plugins/plugin-auth/src/auth-manager.ts` | A `trustProxy`
option. `resolveClientAddress` / `trustsForwardedHeaders`.
`isClientIpAllowed` refuses an undetermined address while ranges are
set. `ipMatchesRange` compares an IPv4-mapped IPv6 address as its IPv4
address: a dual-stack socket reports IPv4 peers that way, and the gate
now judges the peer. `handleRequest(request, { remoteAddress })`
establishes the address once and carries it to the D7 stamp in
better-auth's request state. The `allowedIpRanges` TSDoc no longer
states the fail-open. |
| `packages/plugins/plugin-auth/src/request-client-address.ts` (new) |
The request-state slot that carries the established address to the
sign-in after-hook. |
| `packages/plugins/plugin-auth/src/auth-plugin.ts` | The D5 middleware
judges `resolveClientAddress` over the request's headers and transport
peer. The wildcard route hands the same peer to `handleRequest`. Two
once-per-process operator warnings. |
| `packages/cli/src/commands/serve.ts` | `objectstack serve` passes
`server.trustProxy` to `AuthPlugin`, as it already does to the
dispatcher. |
| `packages/services/service-settings/src/manifests/auth.manifest.ts`,
`translations/{zh-CN,ja-JP,es-ES}.ts` | The `allowed_ip_ranges` help
text says which address is judged. It used to say a trusted proxy
setting `X-Forwarded-For` was enough. |
| `content/docs/permissions/authentication.mdx` | The
`allowed_ip_ranges` row, which stated the old IP source and the
fail-open. |
| `.changeset/24739-auth-ip-allowlist-trust-rule.md` | patch:
plugin-auth, runtime, cli, service-settings. |
**Why the rule lives in plugin-auth.** `@objectstack/runtime` depends on
`@objectstack/plugin-auth`: its limiter already counts through
`./rate-limit-storage`. So plugin-auth cannot import the runtime's copy
without a dependency cycle. The rule moved, and the runtime imports it
back. The package root's export surface is unchanged.
**Why the peer is read from the Hono context.** The D5 gate and the
route that feeds the D7 audit are raw-app mounts. They receive the Hono
context, not an `IHttpRequest`. The read mirrors the transport's own
`readRemoteAddress` (`plugin-hono-server/src/adapter.ts`), and returns
nothing on a runtime that exposes no socket.
## Pins, one per branch
`packages/plugins/plugin-auth/src/client-ip-allow-list-trust-rule.test.ts`
mounts `AuthPlugin.registerAuthRoutes` on a real Hono app in front of a
real `AuthManager` on the installed better-auth. It supplies the peer
the way `@hono/node-server` does. Every refusal asserts `status` and
`code`.
- behind a declared trusted proxy, the allow-list judges the forwarded
client address
- without a declared trusted proxy, the allow-list judges the connection
peer and a forwarded header changes nothing
- an address the server could not establish is refused while ranges are
set, and the operator is told why once
- with no ranges configured nothing is judged, whatever the address
- an IPv4 peer reported in IPv4-mapped form is judged as its IPv4
address
- (D7) records the connection peer, not a forwarded header, when no
proxy is trusted
- (D7) records the forwarded client address the gate admitted when the
proxy is declared trusted
- (D7) records no address when none could be established
`auth-manager.test.ts`: the old "fails OPEN when the client IP cannot be
determined" case is now "refuses an address the server could not
establish once ranges are configured". Two cases are new: the
IPv4-mapped matcher, and `resolveClientAddress` believing forwarded
headers only under `trustProxy`.
## Ablations
Each leg was committed first and restored from `HEAD`: `git checkout
HEAD -- PATH`, or `scripts/ablation-replace.mjs`, which wraps the run.
Each restore is proven by the blob hash matching `HEAD` and an empty
`git diff HEAD`. The pins import `src` relatively, so no `dist/` is
involved.
| Leg | Mutation | Red | Green |
|---|---|---|---|
| A1 | `auth-plugin.ts` + `auth-manager.ts` at base `795ff708d8` (the
old behaviour) | 8: the untrusted-proxy, undetermined and IPv4-mapped
pins; D7 peer; D7 none; the 3 new or flipped units | the trusted-proxy,
no-ranges and D7 trusted pins. These are the preservation half: the old
code believed headers too |
| A2 | `client-address.ts`: the trust branch ignored | 3: the
trusted-proxy pin, D7 trusted, the unit's trusted leg | the rest |
| A3 | `isClientIpAllowed`: no ranges refuses | 4: the no-ranges pin,
its unit, and the two D7 pins served with no ranges | the rest |
| A4 | the wildcard route hands no peer to `handleRequest` | 1: D7 peer
| the rest |
## Verification
Commit readings. The merge at `9f60a2ba3c` brought one `main` commit
that touches one dogfood test file and none of these packages.
- `c42164ff22`: `@objectstack/plugin-auth` build exit 0
(`check-dts-emitted` 3/3), typecheck exit 0 (test layer compiles, no new
debt), `vitest run` 166 files, 3188 passed, 10 skipped.
- `c42164ff22`: `@objectstack/runtime` typecheck exit 0. 11 files that
exercise the rule (`security/inbound-rate-limit`, `endpoint-policy`,
`endpoint-policy.auth-gate.pin`,
`dispatcher-plugin.rate-limit.integration`,
`dispatcher-plugin.endpoint-fallback.integration`,
`dispatcher-plugin.multi-tenant-endpoint.integration`,
`http-metrics-inbound-coverage.hono.integration`,
`http-dispatcher.auth-gate-outage.pin`, `api-endpoint-step`,
`error-envelope.conformance`,
`domains/anonymous-gate-actions-automation`): 244 passed. The rest of
the runtime suite is declared to CI.
- `c42164ff22`: `@objectstack/service-settings` typecheck exit 0, 53
files, 894 passed.
- `9f60a2ba3c`: `@objectstack/cli` closure build 60/60, typecheck exit
0, `vitest run --project unit` 314 files green. Two
`published-subpath-*` pins first refused as PREREQUISITE NOT MET
(`packages/cli` unbuilt). After `pnpm --filter @objectstack/cli build`
they passed, 29/29. The integration layer is declared to CI.
- `9f60a2ba3c`: `node scripts/pm/dispatch-gates.mjs --commands` derived
116 commands, and all 116 exit 0. Three of them (`check:skill-examples`,
`check:dual-build-cjs-loads`, `check:i18n-coverage`) first exited 3 for
unbuilt packages and were re-run after a cached repo build. `--ran`: 116
derived, 116 run, 0 NOT-MEASURED, 0 UNRUN.
## Cross-lane surfaces
- `packages/runtime/src/security/inbound-rate-limit.ts` (`domain:cli`):
declared in the claim.
- `packages/cli/src/commands/serve.ts` (`domain:cli`): not in the
claim's file surface. It is one option line, and it is what lets
`server.trustProxy` reach the gate under `objectstack serve`.
- `packages/services/service-settings` (help text) and
`content/docs/permissions/authentication.mdx`: text this change made
false. Neither is in the claim's file surface.
## Acceptance notes
- ADR-0069 D5 names the gate's IP source as "extract client IP
(`x-forwarded-for` / `cf-connecting-ip`, trust-proxy aware)". This PR
implements "trust-proxy aware" as the runtime's rule, which reads
`X-Forwarded-For`, then `X-Real-IP`. `CF-Connecting-IP` is no longer
read. The ADR text is not edited here.
- The spec's `server.trustProxy` description names one consumer, the
inbound rate limiter. This change adds a second: the auth allow-list and
the login audit. `packages/spec` is not edited here.
- Other HTTP doors into `AuthManager.handleRequest` pass no transport
peer. Their D7 stamp therefore records no address unless
`server.trustProxy` is declared. Their D5 coverage was not measured
here.
- `objectstack dev` (`plugin-dev`) composes `AuthPlugin` without
`trustProxy`, the same way it composes the dispatcher without it.
- `docs/HARDENING.md` shows a hand-written limiter keyed on
`x-forwarded-for`. It is unchanged here.
- Disclosure follows the maintainer's ruling recorded on #9387. This PR
states the rule and its refusals only.
---
_Generated by [Claude
Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth): the auth IP allow-list judges the client address the server established (#24739)
9
+
10
+
Clause-②: no
11
+
12
+
The `allowed_ip_ranges` auth setting (ADR-0069 D5) and the `last_login_ip` login audit (D7) now establish a request's client address by the same rule as the inbound rate limiter: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`. Otherwise the address is the connection's peer address.
13
+
14
+
**For operators who set `allowed_ip_ranges`:**
15
+
16
+
- Behind a reverse proxy or load balancer, declare `server: { trustProxy: true }` in the stack before you upgrade. Without it, the allow-list judges the proxy's own address for every request.
17
+
- A request whose client address cannot be established (the transport reports no peer address, and no forwarded header is trusted) is now refused with `403 IP_NOT_ALLOWED`. Before, it was admitted. The first such refusal logs a warning that names `allowed_ip_ranges` and `server.trustProxy`.
18
+
-`CF-Connecting-IP` is no longer read. Behind Cloudflare, declare `server.trustProxy`, and the `X-Forwarded-For` it sets is used.
19
+
- A dual-stack listener reports an IPv4 client in IPv4-mapped form (`::ffff:203.0.113.7`). That address is now matched as the IPv4 address it denotes.
20
+
- With `allowed_ip_ranges` unset or empty, nothing changes.
21
+
22
+
**`last_login_ip`** records the address the allow-list judged, or no address when none could be established. It is no longer read from a forwarded header the deployment does not trust.
23
+
24
+
**By package:**
25
+
26
+
-`@objectstack/plugin-auth`: the rule is published as the dependency-free `@objectstack/plugin-auth/client-address` entry (`resolveClientAddress`). `AuthPlugin` and `AuthManager` take a `trustProxy` option. `AuthManager.handleRequest` takes an optional second argument that carries the transport's `remoteAddress`.
27
+
-`@objectstack/runtime`: the inbound rate limiter imports the rule from that entry instead of keeping its own copy. Its keys are unchanged.
28
+
-`@objectstack/cli`: `objectstack serve` passes the stack's `server.trustProxy` to `AuthPlugin` as well as to the dispatcher. A host that composes `AuthPlugin` itself passes `trustProxy` the same way.
29
+
-`@objectstack/service-settings`: the `allowed_ip_ranges` help text says which address is judged.
Copy file name to clipboardExpand all lines: content/docs/permissions/authentication.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -590,7 +590,7 @@ often before the account-level lock engages.
590
590
591
591
| Setting | Effect |
592
592
|---|---|
593
-
|`allowed_ip_ranges`| Restrict authenticated access to one or more IPv4 CIDR ranges. IP is extracted with trust-proxy awareness (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`); requests whose IP can't be determined fail open. |
593
+
|`allowed_ip_ranges`| Restrict authenticated access to one or more IPv4 CIDR ranges (or exact IPs). The address judged is the one the server establishes, by the same rule as the inbound rate limiter: `X-Forwarded-For` / `X-Real-IP` only when the stack declares `server.trustProxy`, the connection's peer address otherwise. Behind a reverse proxy, declare `server.trustProxy`, or the allow-list judges the proxy's own address. A request whose address cannot be established is refused with `403 IP_NOT_ALLOWED`, like one outside the ranges. `last_login_ip` records the same address. |
0 commit comments