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 5a878a3
Browse filesBrowse the repository at this point in the historyBrowse files
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)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDpDyoNB672KbyavvfQQJzv)_
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 #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.
<details><summary>Tests</summary>
- `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.
</details>
<details><summary>Ablations (scripts/ablation-replace.mjs; tests import
src directly, no build between legs)</summary>
| # | 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.
</details>
<details><summary>Gates (head aa57f6d, after merging main
fdad178)</summary>
- `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.
</details>
## 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
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic
(untouched).
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block is
Chinese by the Communication rule).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Utp6CXwcgsAkqebfzjnfiS
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth): an IPv6 CIDR in the allowed IP ranges admits the IPv6 clients inside it, at the sign-in gate and at the settings save check
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
`auth.allowed_ip_ranges` (ADR-0069 D5) promises CIDR ranges, but its matcher read only IPv4 CIDR and exact addresses: an IPv6 entry such as `2001:db8::/32` was compared as text, so it never matched a real client. The sign-in gate then refused every IPv6 client the list named, and the settings save check refused the save for an administrator connected over IPv6 from inside it. `ipMatchesRange` now reads an IPv6 CIDR as the first `n` of 128 bits, and both doors use it, so they keep judging the same address the same way.
11
+
12
+
- An exact IPv6 entry matches the address it denotes however it is spelled (`2001:DB8:0:0::1` matches `2001:db8::1`).
13
+
- An IPv4-mapped address, dotted (`::ffff:203.0.113.5`) or hex (`::ffff:cb00:7105`), is still judged as the IPv4 address it maps. A zone identifier on an address (`fe80::1%eth0`) is dropped before matching.
14
+
- Families never cross: an IPv4 range never admits a pure IPv6 address, and an IPv6 range never admits an IPv4 one.
15
+
- An IPv6 prefix must be a whole number from 0 to 128. An entry with any other prefix matches only its base address, as it did before. IPv4 entries are read exactly as before.
0 commit comments