Repository navigation
security: the auth IP allow-list does not resolve the client IP the way the runtime's trust-proxy rule does, and admits an undetermined IP (detail withheld under the disclosure ruling) #24739
Description
Filing gate ③ a task the maintainer set, in the repo:hotcrm seat 2 chat on 2026-10-20 (verbatim): 「登录 IP 白名单信任可伪造的 IP 头,取不到 IP 时还放行」. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r, seat post objectstack-ai/hotcrm#2180) after a source read for the maintainer's question on gateway and WAF needs. ⛔ Not a claim. Domain, type and grade are triage's.
Disclosure: this is an authentication finding. Under the maintainer's disclosure ruling (2026-08-18, recorded on #9387, 5323310017), this card records the defect's existence, where it lives and the governing text only. No reproduction or exploit path is published. The detail stays with the filing seat and the maintainer.
Reader (who acts): objectstack triage routes it to the owner of packages/plugins/plugin-auth (ADR-0069 D5, network gating).
What is wrong (read from source on objectstack main 9b2953d62; not measured)
packages/plugins/plugin-auth/src/auth-plugin.ts, the ADR-0069 D5 middleware (about lines 2641–2665), resolves the client IP forisClientIpAllowedby its own rule, not by the runtime's trust-proxy rule (below). So the IP it judges is not one the server has established.packages/plugins/plugin-auth/src/auth-manager.ts,isClientIpAllowed(about line 9605): an undetermined IP is admitted (if (!ip) return true; // undetermined → fail-open). The option's TSDoc (about lines 1029–1036) states this as intended.- The D7 login audit (
auth-manager.ts, about lines 2843–2853) stampslast_login_ipby the same rule, so the audit trail inherits it.
Governing text
- The runtime already has the rule this gate should share:
packages/runtime/src/security/inbound-rate-limit.tsresolveRateLimitKey. It reads forwarded headers only whentrustProxyis set, otherwise it uses the transport peer, "which a client cannot influence". An undetermined caller gets one shared bucket, which "deliberately over-throttles rather than failing open". Its docs (content/docs/references/system/stack-server.mdx,trustProxy) say the same. - An allow-list is a gate. The runtime's own principle for its limiter, that an unlimited unknown is the one outcome it must never produce, applies here with more force.
Expected (proposed; triage owns it)
- The D5 gate and the D7 audit resolve the client IP through the same trust rule as the inbound limiter: forwarded headers only behind a declared trusted proxy, the transport peer otherwise.
- With ranges configured, an undetermined client IP is refused, with a clear operator log line, rather than admitted. Or the server refuses to enable ranges at all when no trustworthy IP source exists.
- A pin per branch: trusted proxy, untrusted proxy, no IP source.
Dedupe (objectstack issues, all states, semantic search)
- "auth IP allow-list allowedIpRanges trusts x-forwarded-for spoofable fails open trustProxy": 0 hits.
- "IP allow-list x-forwarded-for spoof": 0 hits.
- "allowedIpRanges network gating ADR-0069 D5": 3 hits. security: a session the ADR-0069 auth gate holds can still be served by the storage route family (detail withheld under the disclosure ruling) #24366 (storage family serves a held session), core: put the exact approvals act path on the ADR-0069 auth-gate allow-list, token-only as ADR-0043 sets it and as the self-hosted mount already serves it (segment 3 of ruling A on #22438) #22577 (approvals act path) and owd-save-gate: the ADR-0090 D11 authoring gate never runs on any host-config deployment (keyed on environmentId, not authoringChannel) #7674 (OWD save gate) are all closed, and none is on point. This query also serves as the control.
Dedupe words: allowedIpRanges client IP resolution; ADR-0069 D5 trustProxy; isClientIpAllowed fail-open; last_login_ip forwarded header.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 20, 2026
Triage: p1 ·
bug·security· lanedomain:services·area:identity·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T05:00ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. Fresh read on
origin/main580fd4ed3c. Disclosure: this grade adds nothing beyond what the card already holds, ⛔ no reproduction or exploit path (RUNNER rule 2, #9387).Lane.
packages/plugins/plugin-authisdomain:services(SKILL.md:162).Why p1. An allow-list is a gate. The card names the two places it does not hold its own rule (the IP source and the undetermined case), against the runtime's own trust-proxy rule. It reaches only deployments that configure
allowedIpRanges. For them the gate is the security control, so this is a wall, not a tax. ③, maintainer-directed.18.0. The feature is opt-in, so this is patch-eligible. It is on the maintainer list.
Shape: as the card proposes: one shared trust rule, refusal when the IP is undetermined, and a pin per branch. ⛔ No pin names that state a recipe.
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 5 · 2026-10-20T05:31Z
Session:
session_011TdZ8GjixNZFLz1xJD9zzKAccount:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24739-auth-ip-allowlist-trust-ruleWorktree:
objectstack-issue-24739Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/plugins/plugin-auth/src/auth-plugin.ts(the ADR-0069 D5 middleware's client-IP resolution),auth-manager.ts(isClientIpAllowed, theallowedIpRangesoption TSDoc, the D7 login audit'slast_login_ip), their tests, and one new.changeset/24739-*.md. Cross-lane, declared before the queue if touched:packages/runtime/src/security/inbound-rate-limit.ts(domain:cli), only if the trust rule must be shared from there. ⛔ Notpackages/spec/**(theIHttpRequest.remoteAddresscontract is read, not edited), ⛔ not the rate limiter's behaviour (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default (opus), quoting this dispatch's --tier output "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable"; default because an authentication gate's IP source is a security judgementClause-②: no
Responsibility:
plugin-auth's ADR-0069 D5 gate resolves the client IP by its own rule, not the runtime's trust-proxy rule, and admits an undetermined IP | the platform path is the runtime's trust rule (resolveRateLimitKey, and the IHttpRequest.remoteAddress contract "never substitute a header … never fall open") | every deployment that configures allowedIpRangesThread-read: 6283769131
Serial constraints cleared:
No open PR touchesplugin-security/src/security-plugin.ts,explain-engine.ts,delegated-admin-gate.ts,plugin-auth/src/auth-plugin.ts,auth-manager.tsorruntime/src/security/inbound-rate-limit.ts(all 11 open PRs' files read). The epic's declared territory (#15194, body read at this act) lists none of them. This round dispatches #24746, #24739 and #24736 together on disjoint files; #24484 (PR #24761, this seat) edits onlydefault-permission-sets.ts, its pins andplatform-objectscomments.Disclosure: under the maintainer's ruling recorded on #9387, no reproduction or exploit path goes on this card, the PR or any comment.
Readings taken on
origin/main795ff708d8.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{ "issue": 24739, "status": "done", "branch": "claude/issue-24739-auth-ip-allowlist-trust-rule", "pr": "https://github.com/objectstack-ai/objectstack/pull/24795", "session": "session_011TdZ8GjixNZFLz1xJD9zzK (mode:subagent, the parent's harness-stamped id, as the Claim names it)", "premise_still_valid": true, "summary": "The ADR-0069 D5 allow-list and the D7 login audit now establish the client address by the runtime's trust rule: forwarded headers only under server.trustProxy, the transport peer otherwise. With ranges configured, an address that cannot be established is refused (403 IP_NOT_ALLOWED, the out-of-range code and status) and explained once in the operator log; with no ranges, nothing changes. The rule moved unchanged from runtime's resolveRateLimitKey into plugin-auth's dependency-free ./client-address subpath (runtime depends on plugin-auth, so it cannot be imported the other way), and the runtime imports it back with its keys unchanged. objectstack serve now passes server.trustProxy to AuthPlugin; the allowedIpRanges TSDoc, the settings help text (4 locales), the authentication doc row and a 4-package patch changeset (Clause-②: no) say what is judged.", "files_changed": [ ".changeset/24739-auth-ip-allowlist-trust-rule.md", "content/docs/permissions/authentication.mdx", "packages/cli/src/commands/serve.ts", "packages/plugins/plugin-auth/package.json", "packages/plugins/plugin-auth/tsup.config.ts", "packages/plugins/plugin-auth/src/client-address.ts (new)", "packages/plugins/plugin-auth/src/request-client-address.ts (new)", "packages/plugins/plugin-auth/src/auth-manager.ts", "packages/plugins/plugin-auth/src/auth-plugin.ts", "packages/plugins/plugin-auth/src/auth-manager.test.ts", "packages/plugins/plugin-auth/src/client-ip-allow-list-trust-rule.test.ts (new)", "packages/runtime/src/security/inbound-rate-limit.ts", "packages/services/service-settings/src/manifests/auth.manifest.ts", "packages/services/service-settings/src/translations/zh-CN.ts", "packages/services/service-settings/src/translations/ja-JP.ts", "packages/services/service-settings/src/translations/es-ES.ts" ], "tests": "Head 9f60a2ba3c (merge of origin/main 74f78159af, which touches one dogfood test file and none of these packages; package readings below were taken at c42164ff22, the same package sources). Every heavy run went through os-verify-lock with its VERDICT line read. [c42164ff22] closure build: turbo build --filter=@objectstack/runtime^... + plugin-auth, 32/32 tasks, VERDICT command-exit 0. plugin-auth: build exit 0 (check-dts-emitted 3/3, dist/client-address.mjs 679 B), typecheck exit 0 (test layer compiles, no new debt), vitest run 166 files, 3188 passed, 10 skipped. runtime: typecheck exit 0; 11 files exercising 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, VERDICT command-exit 0. service-settings: typecheck exit 0, 53 files, 894 passed. [9f60a2ba3c] cli: closure build 60/60, typecheck exit 0, vitest run --project unit 312 passed plus 2 files (published-subpath-console.pin, published-subpath-hook-body.pin) that first refused PREREQUISITE NOT MET (packages/cli unbuilt), 29/29 green after pnpm --filter @objectstack/cli build. Pins (client-ip-allow-list-trust-rule.test.ts, real raw-app mounts, code+status on every refusal): trusted proxy honours the forwarded address; untrusted proxy judges the peer; undetermined refused + one operator line; no ranges unchanged; IPv4-mapped peer; D7 peer / trusted forwarded / none. Ablations, each restored from HEAD with blob hash == HEAD and empty git diff HEAD proven (pins import src relatively, no dist involved): A1 auth-plugin.ts + auth-manager.ts at base 795ff708d8 (old behaviour): 8 red (untrusted, undetermined, IPv4-mapped, D7 peer, D7 none, 3 units), green = trusted-proxy, no-ranges, D7 trusted (the preservation half: the old code believed headers too); A2 client-address.ts trust branch ignored (ablation-replace anchor 1-0): 3 red (trusted pin, D7 trusted, unit leg); A3 isClientIpAllowed no-ranges returns false: 4 red (no-ranges pin, its unit, two no-ranges D7 pins); A4 wildcard route hands no peer to handleRequest: 1 red (D7 peer). Direction as predicted for every leg.", "gates": "Local subset at 9f60a2ba3c: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 116 commands; all 116 run, each exit captured before any pipe: 116 exit 0. Three (pnpm --filter @objectstack/spec run check:skill-examples, pnpm check:dual-build-cjs-loads, pnpm check:i18n-coverage) first exited 3 (PREREQUISITE NOT MET, unbuilt packages) and exit 0 after a cached repo build (turbo build, 76/76); dual-build-cjs-loads: 112 require entries across 69 packages load. --ran with COMMAND :: exit CODE lines: 116 derived, 116 run, 0 NOT-MEASURED (derived zero), 0 UNRUN, exit 0. check:nul-bytes in the set, exit 0. PR #24795 check-runs read once at report time on 9f60a2ba3c (39 runs): success = Auto Label, Dogfood Regression Gate, Spec property liveness, Check PR Size, Check Documentation Links, Validate Package Dependencies, filter, the claim/closing-keyword/single-writer guards; skipped = dogfood shards, Temporal Conformance, Dogfood Verify CLI, Console Pin Gate, Packed-tarball smoke; in_progress = Test Core 1-6/6, Build Core, Build Docs, Type Check (workspace, consumer gates, debt ledger, source gates), Lint (ESLint, docs & code guards, CI release & engine gates, PM dispatch-gates, repo & tooling gates, scoped x3), Governed Surface Queue Guard, Check Changeset, Flag docs affected by code changes. NOT MEASURED locally, declared to CI: the rest of the runtime suite and runtime test:repo, the cli integration layer, pnpm lint.", "deviations": [ "packages/cli/src/commands/serve.ts (domain:cli) is not in the Claim's file surface: one option line, needed for server.trustProxy to reach the gate under objectstack serve. The seat declares it to that lane.", "packages/runtime/src/security/inbound-rate-limit.ts (domain:cli) touched, as the Claim pre-declared.", "packages/services/service-settings (help text, manifest + zh-CN/ja-JP/es-ES) and content/docs/permissions/authentication.mdx are not in the Claim's file surface: text this change made false.", "Local runtime suite narrowed to the 11 files exercising the rule; the rest, runtime test:repo and the cli integration layer are declared to CI.", "scripts/check-platform-checklist.mjs --changed-from not run: no exported symbol renamed or deleted (one subpath entry and members added).", "Commits carry the model-free trailer pair AGENTS.md prescribes; the harness attribution reminder named a model identifier and was not used.", "origin/main merged once before opening the PR (one commit, a dogfood test file only)." ], "mcp_calls": "0 — no MCP GitHub tool used", "api_writes": "2 — relay strokes through scripts/pm/fleet-write/dispatch.mjs (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24795/assignees (os-justin), body read back byte-identical 9509/9509; (2) comment → POST /repos/objectstack-ai/objectstack/issues/24739/comments (this report). git push x4 (not REST). Reads: single-card REST reads of #24739 and its comments, PR #24795, its check-runs.", "open_questions": [ { "question": "Where does the shared client-address rule live? Measured: runtime depends on plugin-auth (it already imports ./rate-limit-storage), so plugin-auth cannot import runtime's copy without a cycle.", "options": [ "A (chosen): plugin-auth ./client-address subpath, dependency-free, runtime imports it back — the ./rate-limit-storage precedent; in this lane plus the Claim's pre-declared runtime file", "B: @objectstack/core beside the IHttpRequest re-export — the natural home of a transport rule; not this lane, adds a core export", "C: @objectstack/types — domain:cli, adds a utility export" ], "recommendation": "A. Actual need: two measured consumers (the inbound limiter; D5 + D7), no third found. Long-term: one copy either way; B reads better architecturally but costs a cross-lane core export for no extra consumer. Preventing AI mistakes: every option leaves one copy, and A's isolated entry keeps runtime's load graph free of better-auth. Focus: A adds one subpath and no new package edge. Revisit B only if a third consumer appears." }, { "question": "How do plugin-auth's raw-app mounts learn the transport peer? They receive the Hono context, not an IHttpRequest.", "options": [ "A (chosen): plugin-auth mirrors plugin-hono-server's private 6-line readRemoteAddress (c.env.incoming socket); a runtime exposing nothing reads as no peer", "B: plugin-hono-server exports its reader (domain:cli) and plugin-auth gains a runtime dependency on the transport package", "C: move the gate onto the IHttpServer use() seam (spec'd remoteAddress) — rejected: that seam finishes its chain before the route runs, so it cannot carry the address to the D7 stamp" ], "recommendation": "A. If the two reads drift, the gate fails closed (refusal plus the operator line), never open. B is the tidier single owner if the transport lane wants it." }, { "question": "How does server.trustProxy reach the gate? No plugin-readable stack-config seam exists: PluginContext has none, and only serve.ts reads server.trustProxy.", "options": [ "A (chosen): AuthManagerOptions.trustProxy, passed by objectstack serve exactly as it passes the dispatcher (cross-lane serve.ts, one line); a host composing AuthPlugin passes it itself", "B: the dispatcher publishes the rule as a kernel service — new service vocabulary, cross-lane runtime, and a silent absence when the dispatcher is not mounted" ], "recommendation": "A: explicit composition, the same seam the limiter uses. plugin-dev composes neither the dispatcher nor auth with trustProxy, which keeps them consistent." }, { "question": "ADR-0069 D5's mechanism cell reads verbatim: '**[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`'. It names a header source, qualified as trust-proxy aware, and does not state the fail-open. Implemented per the maintainer's direction as the runtime's rule (X-Forwarded-For then X-Real-IP under trustProxy); CF-Connecting-IP is no longer read.", "options": [ "A: the seat routes an ADR-0069 D5 amendment naming the shared rule and the undetermined refusal (docs/adr, Tier H)", "B: leave the ADR text as is" ], "recommendation": "A: the cell's header list no longer matches the code, and docs/adr is the seat's to route." }, { "question": "The spec's server.trustProxy describe() (stack-server.zod.ts, the consumer table and the generated stack-server.mdx) names one consumer, the inbound rate limiter. This change adds a second: the auth allow-list and the login audit. packages/spec was not edited, per the dispatch.", "options": [ "A: a spec follow-up (describe text + consumer table + gen:schema/gen:docs), routed by the seat", "B: leave it" ], "recommendation": "A: the narrowness principle in that file is that each consumer is named beside its key." }, { "question": "Clause-②: the PR body and the changeset carry 'Clause-②: no' exactly as dispatched. Facts for the seat: the diff adds a public subpath export, an AuthManagerOptions key and two public AuthManager methods, plus an optional handleRequest argument; and for deployments with allowed_ip_ranges it refuses requests that were admitted before.", "options": [ "A: keep no + patch (triage: opt-in feature, patch-eligible)", "B: yes ⇒ at least minor", "C: no (narrowing) ⇒ BREAKING plus an ADR-0087 marker" ], "recommendation": "The seat's call. A is what was dispatched and matches triage's patch ruling." } ], "out_of_scope_findings": [ "class: b · Seam: spec:contracts/http-server.ts IHttpRequest.remoteAddress (forwarded headers believable only under server.trustProxy) → runtime:packages/plugins/plugin-auth/src/auth-manager.ts vendor auth instance options (no advanced.ipAddress) and the vendor's own client-IP reader (@better-auth/core 1.7.3 dist/utils/ip.mjs:196) · reach: named real producer — plugin-auth's own vendor auth instance, composed in every objectstack serve deployment · evidence: source read at 9f60a2ba3c, zero hits for ipAddressHeaders / disableIpTracking under packages/plugins/plugin-auth/src; a second client-IP reader outside the D5/D7 seams is not governed by server.trustProxy; detail beyond location withheld on public surfaces under the disclosure ruling · dedupe words: better-auth advanced.ipAddress trustProxy; ipAddressHeaders; vendor auth client IP reader; session ip_address forwarded header", "carrier: none (承接者:无) · D5 coverage on HTTP doors into AuthManager.handleRequest other than plugin-auth's own route registration was not measured; those doors pass no transport peer, so their D7 stamp records no address unless trustProxy is declared · noted in the PR's Acceptance notes, not filed", "carrier: none (承接者:无) · docs/HARDENING.md shows a hand-written limiter keyed on x-forwarded-for, untouched by this change · noted, not filed" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Ruled: not an 18.0 gate (patch-eligible) · recorded by the triage seat ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T07:02ZThe maintainer, in this seat's session, verbatim: 「进 18.0 同意你的清单」. That answers the triage seat's 18.0 list:
What it binds. The 18.0.0 cut does not wait for this card. It is opt-in network gating, so it is patch-eligible. PR #24795 is in flight. If it lands before the cut, it ships in 18.0.0 anyway, and if not, in 18.0.1. ⛔ No
target:v18.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24795 comment 6285566524 ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T07:17Z. Landing goes through the merge queue.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T08:11Zadded_to_merge_queue2026-10-20T07:19Z →merged2026-10-20T07:55Zorigin/mainby content25e16ab00c(one parent,21301a3406)..changeset/24739-auth-ip-allowlist-trust-rule.mdandplugin-auth/src/client-address.tsare present.isClientIpAllowedrefuses an undetermined address. The runtime limiter imports@objectstack/plugin-auth/client-addressState change in this stroke:
pm:dispatchedoff and the assignee cleared (the card closedcompletedon the merge). Follow-ups:server.trustProxydescribe now under-describe the code. Both are prose on the spec seat's surface, filed next.Generated by Claude Code