Skip to content

fix(plugin-auth): an IPv6 CIDR in the allowed IP ranges admits the IPv6 clients inside it, at the sign-in gate and the save check - #30392

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-g6bjfy
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-g6bjfy

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#2 · os-bill · session_01Utp6CXwcgsAkqebfzjnfiS

Fixes #30320

Clause-②: no — a fix: an IPv6 CIDR entry now admits the clients the list already names; no export, no new refusal, no settings key or manifest shape changes.
Metadata-protocol: none

维护者速读

  • 改了什么:「允许的 IP 段」里的 IPv6 网段(如 2001:db8::/32)现在按网段生效:登录闸门放行网段内的 IPv6 用户,设置页保存时也认得出保存者的 IPv6 地址在网段内。精确的 IPv6 地址不论大小写或缩写写法都能匹配。
  • 为什么改:设置说明写的是「CIDR 网段」,但匹配只认 IPv4 网段,IPv6 网段被当成一串文字比对,永远匹配不上;结果是网段里的 IPv6 用户全部登录被拒,IPv6 管理员保存这份列表也被拒。
  • 风险与回滚:只放宽,不收紧。IPv4 条目的读法一字未改;IPv4 网段不会放行 IPv6 地址,IPv6 网段也不会放行 IPv4 地址;写错的 IPv6 前缀只匹配它的基地址,和以前一样不会放大。两道门共用同一个匹配函数,判法一致。回滚即撤本 PR。
  • 先看:packages/plugins/plugin-auth/src/auth-manager.ts 的 ipMatchesRange 与 readMatchedAddress(地址族判定与前缀比较);packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts 末尾的 #30320 用例(经真实设置路由保存和登录闸门两道门)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/allowed-ip-ranges.pin.test.ts,预期 1 个文件、11 条全绿。
  • 需要你做的:无,席位落地。

What changes

The rule: an allowed-IP entry is read as the address or range it names, in its own family, at every door that judges the list.

  • ipMatchesRange reads IPv6 CIDR (@objectstack/plugin-auth). It read IPv4 CIDR and exact addresses only; an IPv6 entry such as 2001:db8::/32 fell to an exact text comparison against its base and never matched a real client. Both sides are now read as the address they denote (readMatchedAddress): an IPv4 address as a uint32, any other IPv6 address as eight 16-bit groups through the file's existing parseIpv6Literal (:: elision, embedded IPv4 tail). An IPv6 range compares the first n of 128 bits. The sign-in gate (AuthManager.isClientIpAllowed) and the settings save check (allowed-ip-ranges.ts, from fix(plugin-auth)!: clearing the allowed IP ranges applies at once, and a console save that leaves out the saving address is refused #30309) both call it, so they keep judging the same address the same way.
  • Exact IPv6 entries compare by value. 2001:DB8:0:0::1 matches 2001:db8::1; 0:0:0:0:0:0:0:1 matches ::1.
  • IPv4-mapped addresses stay IPv4. A mapped address on either side, dotted (::ffff:203.0.113.5, as before) or hex (::ffff:cb00:7105, now too), is the IPv4 address it maps, so a dual-stack listener's IPv4 peers keep matching IPv4 ranges.
  • Families never cross. An IPv4 range never admits a pure IPv6 address (0.0.0.0/0 does not admit ::1), and an IPv6 range never admits an IPv4 one (::/0 does not admit 203.0.113.5 nor its mapped form).
  • Zone identifiers are dropped. fe80::1%eth0 is judged as fe80::1.
  • Malformed entries do not widen. An IPv6 prefix must be a whole number from 0 to 128; any other prefix (/129, an empty /, /3x) leaves the entry matching only its base address, which is what the old exact-text fallback did. An entry that is not an address is still compared as text, which an address the server established never equals. IPv4 entries are read exactly as before, prefix parsing included, so no IPv4 list changes what it admits.
  • unmapIpv4 is gone (its job moved into readMatchedAddress; no other caller). The isClientIpAllowed docblock and one sentence of the client-address-canonical.ts header, which said the allow-list compares exact IPv6 entries as text, are corrected.
  • Not changed: the allowed_ip_ranges help text (it promises CIDR ranges unqualified, which now holds for both families), auth-plugin.ts, packages/spec.

Why

#30320: the setting promises CIDR ranges, and an IPv6 range locked out every IPv6 client inside it at sign-in and, after #30309, refused the save for an administrator connected from inside it, with copy saying the list does not include an address it textually covers.

Verification

Measured first, at d1d90b70 with the new pins: 6 failed | 7 passed (the two door pins, the CIDR pin, the exact-spelling pin, the cross-family pin through the hex-mapped case, and isClientIpAllowed); the malformed-prefix pin passed already, which is the preservation it states.

Tests
  • auth-manager.test.ts › IP allow-list (ADR-0069 D5): five new cases — IPv6 CIDR inside/outside (/32, /56, /128, ::/0, ::1, a zone id); exact IPv6 by value; cross-family (IPv4 range vs ::1 and 2001:db8::5, IPv6 range vs IPv4 and mapped IPv4, hex-mapped vs an IPv4 range, mapped base keeps its IPv4 prefix); malformed IPv6 prefixes never widen past the base; isClientIpAllowed with an IPv6 range plus ::1.
  • allowed-ip-ranges.pin.test.ts › #30320 (the shared harness: real AuthPlugin, real SettingsService with the shipped auth manifest, real settings routes): the gate admits an IPv6 client inside an allowed IPv6 range and refuses one outside and an IPv4 one; a save from an IPv6 address outside is refused (400 SETTINGS_VALIDATION, constraint.clientAddress) and stores nothing, from inside is stored and enforced.
  • pnpm --filter @objectstack/plugin-auth test: Test Files 222 passed (222), Tests 3925 passed | 10 skipped (3935), at d9855b3c and again at aa57f6dd. typecheck: exit 0.
Ablations (scripts/ablation-replace.mjs; tests import src directly, no build between legs)
# Mutation Result
A1 IPv4 branch: drop the address-family check never matches across address families red (1 failed)
A2 IPv6 branch: an IPv4 address passes the family check gate door pin, cross-family, isClientIpAllowed red (3 failed)
A3 IPv6 prefix read with Number() (empty prefix becomes /0) never widens a malformed IPv6 range red (1 failed)
A4 prefix comparison skipped both door pins and the CIDR pin red (3 failed)

Each mutation landed (anchor 1 to 0, blob changed); each restore leg reported blob equal to HEAD and git diff HEAD empty.

Gates (head aa57f6d, after merging main fdad178)
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 62 commands derived for 5 paths (189 changed lines). Each run with its exit code recorded before any pipe; --ran: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED". All 62 exit 0.
  • Also run, outside the derivation: the four artifact-roster gates whose roster sits under a changed directory (check:authz-resolver, check:changeset-fixed, check:error-code-casing, check:filter-alias-parity) and pnpm lint: all exit 0.
  • pnpm --filter @objectstack/plugin-auth test after rebuilding the closure on the merged head: Test Files 222 passed (222), Tests 3925 passed | 10 skipped (3935); typecheck exit 0.
  • No title ! and no (narrowing) arm, so no full dogfood run is owed.

Acceptance notes

  • Node's net.BlockList was measured and not used: checked against an IPv4 subnet it admits the mapped IPv6 form, and checked as IPv4 against a ::ffff:0:0/96 subnet it admits a plain IPv4 address, so its family rules are not this matcher's; the file already carries an IPv6 parser.
  • A zone identifier is dropped on both sides rather than refused: no save-time refusal is added here.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic (untouched).
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is Chinese by the Communication rule).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CXwcgsAkqebfzjnfiS

…v6 clients inside it

ipMatchesRange read only IPv4 CIDR and exact addresses, so an IPv6 entry
such as 2001:db8::/32 never matched a real client at the sign-in gate or
at the settings save check. Read both sides as the address they denote
and compare the first n of 128 bits for an IPv6 range; IPv4 entries keep
their reading, and families never cross.

Claude-Session: https://claude.ai/code/session_01Utp6CXwcgsAkqebfzjnfiS
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/client-address-canonical.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v18/18-2.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/releases/v18/18-3.mdx (via AuthManager (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/client-address-canonical.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 870ebce333bf0a5794307febe077ab5652cfbfe9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bbc06d90fbd6f4994caa1322a74e62a16b18a047 — the merge of head aa57f6ddcbd8620bc216cb70f2b618ab5c055c91 into base 870ebce333bf0a5794307febe077ab5652cfbfe9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bbc06d90fbd6f4994caa1322a74e62a16b18a047 && git checkout bbc06d90fbd6f4994caa1322a74e62a16b18a047
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 870ebce333bf0a5794307febe077ab5652cfbfe9 aa57f6ddcbd8620bc216cb70f2b618ab5c055c91 && git checkout -B drift-repro 870ebce333bf0a5794307febe077ab5652cfbfe9 && git merge --no-ff aa57f6ddcbd8620bc216cb70f2b618ab5c055c91

node scripts/docs-audit/affected-docs.mjs --json 870ebce333bf0a5794307febe077ab5652cfbfe9

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 870ebce333bf0a5794307febe077ab5652cfbfe9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

席位核对 — ACCEPT · domain:server#2 · head aa57f6ddcbd8620bc216cb70f2b618ab5c055c91

Served-tier: TIER_DEFAULT (declaration face: Clause-②: no, Metadata-protocol: none, no packages/spec/** and no governed path). Implemented-by session_01Utp6CXwcgsAkqebfzjnfiS; Reviewed-by session_01Utp6Cbvcmv8YWFhFJwzjKL.

  • 形态: draft, author objectstack-fleet[bot] (relay pr_create), no assignee, no review request; 5 files, +160 −29; check-governed-merges --pr 30392: NOT governed (0 of 5 paths), 189 lines ≤ 3000. Commits: red pin d66430431b → fix d9855b3c6d → merge of main fdad1786 (aa57f6dd).
  • Diff read (seat): ipMatchesRange reads both sides through readMatchedAddress (IPv4 → uint32 via ipv4ToInt; otherwise parseIpv6Literal, already in the file, after dropping a %zone; ::ffff:0:0/96 dotted or hex folds to IPv4). An IPv4 base keeps the earlier prefix reading and its exact-base fallback; an IPv6 base takes a 1–3 digit prefix ≤ 128, otherwise /128 (base only); families never cross; ::/0 loops zero groups and matches every pure IPv6 address. Unreadable text on either side falls back to a trimmed text comparison, as before. Every former IPv4 and mapped-dotted case reads the same (checked: no-slash IPv4, out-of-range IPv4 prefix, mapped base with an IPv4 prefix). The two doors (isClientIpAllowed, allowed-ip-ranges.ts) still call the one function. client-address-canonical.ts changes a comment only.
  • Declarations: Clause-②: no holds: no export, error code, settings key or manifest shape moves; the change admits clients an entry already names, which is the setting's documented CIDR meaning. Metadata-protocol: none; changeset patch for @objectstack/plugin-auth, the same two lines in the PR body and the changeset; no Skills-docs: line owed.
  • Pins: unit tests for IPv6 prefix, by-value exact match, family separation and malformed IPv6 prefixes; both doors driven through the real settings route and the sign-in gate in allowed-ip-ranges.pin.test.ts. Dev: 6 red on the unfixed tree, 4 ablations red then restored; plugin-auth 3925 passed; derived gates 62/62; lint 0.
  • 门禁 check-runs: 14 success, 13 skipped, 18 in progress, 0 failed at ACCEPT; auto-merge waits on the required contexts.
  • 落地条款: Fixes Allowed IP ranges: an IPv6 CIDR entry never matches at the sign-in gate or the save check (matcher reads IPv4 CIDR and exact addresses only), while the setting promises CIDR ranges #30320. Ordinary queue landing by this seat: pr_ready + automerge_enable on this head. PR fix(plugin-auth)!: the global mfa_required switch cannot be saved on while OS_AUTH_TWO_FACTOR=false keeps two-factor enrollment off (#30302) #30376 and PR fix(auth): say on /auth/config whether email leaves the deployment, so forgot-password can stop claiming a link was sent #30365 edit other files or other hunks of plugin-auth; the queue rebuilds on each merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant