Repository navigation
fix(plugin-auth)!: an allowed IP range that names no range is refused at save and never matches at the gate - #30717
objectstack-fleet[bot] wants to merge 11 commits into
Conversation
…d at every save door An allowed IP range entry is an address, or an address, a slash and a prefix length of 1-2 decimal digits from 0 to 32 (IPv4) or 1-3 from 0 to 128 (IPv6). These pins state that any other entry matches no address and is refused at each door that saves the list. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
… at save and never matches at the gate One reading of an allowed IP range entry now serves the sign-in gate, the settings save check, the settings binding and the deployment's declared list (`parseIpRange`): an address, or an address, "/" and a prefix length of 1-2 decimal digits from 0 to 32 (IPv4) or 1-3 from 0 to 128 (IPv6). Any other entry names no range. - The gate: such an entry matches no address, its own base address included. - The settings save (console and in-process writes): a list holding one is refused with SETTINGS_VALIDATION, invalid_value, the entries in constraint.invalidEntries, before the saving address is judged. - The declared allowedIpRanges (plugin options, auth:configure): refused at construction, naming each entry. - A stored or env-pinned list holding one is applied (the entry matches nothing) and the binding names it once at error. Every well-formed entry keeps its meaning. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
… changeset The authentication guide's allowed_ip_ranges row and its saving rules state that an entry is an address, or an address, "/" and a decimal prefix length within its family, that any other entry is refused on every write and at startup for a declared list, and that one already stored matches no address. The hardening guide's Network row names IPv6 and exact addresses. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
|
security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg Contract reviewServed-tier: Face: the paths decide no face (no published schema, no governed register row; ① Derived judgments
② Semver levelChangeset ③ Boundary flags
④ Design judgmentOn the narrowing face the narrowing is judged against what One must-fix, in a file under review:
Not blocking, recorded for the seat and the lane: the shoulds and the nit in ③; the CI and dogfood readings owed at the landing head; the delta hop covers the merge of Implemented-by: VERDICT: FAIL |
…used at save and admits nobody where stored
Pins the rule at both save doors (the console's PUT /api/settings/auth and an
in-process SettingsService write: 400 SETTINGS_VALIDATION, invalid_value at
allowed_ip_ranges, constraint { type: 'textarea' }, nothing stored), and at the
binding (a value of another form stored before the save check admits no
client, is logged at error, and is replaced by a save of the list as text).
The case that judges an unreadable entry before the saving address now also
asserts the envelope's code and the field error's code.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
…sed at save and admits nobody where stored
The setting is a textarea, so its value is text. readAllowedIpRanges is the
one reading of a whole value, for the save predicate and the settings binding:
text is read as its entries, no value as no list, and any other value names no
list.
- The save predicate refuses such a value first, on every write, with
invalid_value and constraint { type: 'textarea' } and a message in the
caller's language (en, zh-CN, ja-JP, es-ES).
- The binding applies a stored one as a list that matches no address, so the
gate admits nobody while it is in force, and logs one error per distinct
value naming the fix.
- AuthManager.applyConfigPatch's docblock states that an entry naming no range
is applied, not refused, matches no address, and is logged by the binding,
not by the method.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
…used The changeset and the authentication guide state the rule: the setting holds its entries as text, a value in another form is refused at save, and one already stored admits no address and is logged at startup. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 080f5dce84e2f6fc03840aea99060da57fc1bc5f && git checkout 080f5dce84e2f6fc03840aea99060da57fc1bc5f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9b24f3ca8734b5e0ea77cc3cb282c3f8f55a862b 03655cf8e198cb56d9ca9e3df2f8510f2a6ad2b4 && git checkout -B drift-repro 9b24f3ca8734b5e0ea77cc3cb282c3f8f55a862b && git merge --no-ff 03655cf8e198cb56d9ca9e3df2f8510f2a6ad2b4
node scripts/docs-audit/affected-docs.mjs --json 9b24f3ca8734b5e0ea77cc3cb282c3f8f55a862b
|
Both sides kept. The allowed IP range refusal copy now carries every language on the platform locale table, so the value and entry refusals this branch adds carry them too; the two new describes and the Network row keep both sides' content. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
…d value, the one write the override lets through Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
|
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Contract reviewServed-tier: Face: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentOn the narrowing face the narrowing is judged against what Implemented-by: VERDICT: PASS Generated by Claude Code |
|
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc 席位核对ACCEPT · 2026-11-06T08:04Z · head
Generated by Claude Code |
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: updated here —
content/docs/permissions/authentication.mdx(theallowed_ip_rangesrow, and the first two rules under "Saving, clearing and lockout recovery") anddocs/HARDENING.md(the Network row);skills/**teaches noallowed_ip_ranges维护者速读
auth.allowed_ip_ranges(IP 白名单)的每一项只剩一种读法:一个地址,或「地址 / 前缀长度」,前缀只能是十进制数字且落在本地址族范围内(IPv4 为 0 到 32,IPv6 为 0 到 128)。设置值本身必须是文本(每行一项或逗号分隔),列表、数字、对象等非文本值不构成任何列表。登录网关、设置保存检查、部署代码里声明的列表三处共用这一读法。packages/plugins/plugin-auth/src/auth-manager.ts(parseIpRange与ipMatchesRange),allowed-ip-ranges.ts(readAllowedIpRanges;保存检查先判取值形式,再判条目),auth-plugin.ts(设置绑定时点名不规范条目)。pnpm --filter @objectstack/plugin-auth exec vitest run src/allowed-ip-ranges.pin.test.ts,预期全部通过。What changes
An allowed IP range entry now has one reading,
parseIpRangeinauth-manager.ts, shared by every door that judges or saves the list. An entry is an IPv4 or IPv6 address, or an address,/and a prefix length written in decimal digits: one or two digits, from 0 to 32, for an IPv4 base (an IPv4-mapped base included), and one to three digits, from 0 to 128, for an IPv6 base. Blanks around the entry are not part of it. Any other entry names no range. Every well-formed entry keeps its meaning. The setting holds its entries as text, one per line or comma-separated, andreadAllowedIpRangesinallowed-ip-ranges.tsis the one reading of a whole value: a value that is not text (a list, a number, an object) names no list.ipMatchesRange,AuthManager.isClientIpAllowed): an entry that names no range matches no address, its own base address included, so a list holding one admits exactly what its other entries admit.allowedIpRangesWritePredicate): a value that is not text is refused first, with400 SETTINGS_VALIDATION,invalid_valueatallowed_ip_ranges,constraint: { type: 'textarea' }and a message in the caller's language. A list holding an entry that names no range is refused with400 SETTINGS_VALIDATION,invalid_valueatallowed_ip_ranges, the entries inconstraint.invalidEntries, and a message in the caller's language (every language on the platform locale table) naming them. The value and its entries are judged before the saving address, on every write: the console'sPUT /api/settings/authand in-processSettingsServicewrites alike.AuthManagerconstructor,assertAllowedIpRanges): anallowedIpRangesfrom the plugin options or anauth:configurehandler that holds one, or is not an array, is refused at construction, naming each entry.bindAuthSettings): a stored list or anOS_AUTH_ALLOWED_IP_RANGESvalue holding one is applied (the entry matches nothing), and the binding logs oneerrorper distinct list naming the entry and the fix. A stored value that is not text is applied as a list that matches no address, so the gate admits nobody while it is in force, and the binding logs oneerrorper distinct value naming the fix.AuthManager.applyConfigPatch: its docblock states that an entry naming no range is applied, not refused, matches no address, and is reported by the settings binding rather than by the method.allowed_ip_rangesrow and its saving rules, and the hardening guide's Network row.Tests
packages/plugins/plugin-auth/src/auth-manager.test.ts, in the existingIP allow-list (ADR-0069 D5)describe:ipMatchesRangematches no address through an IPv4 entry whose prefix is not 1-2 decimal digits from 0 to 32, nor through an IPv6 entry whose prefix is not 1-3 decimal digits from 0 to 128, nor through an entry that is not an address (each judged against the entry's own base address, an address inside the range it would name, and one outside)./32,/128, bare IPv4 and IPv6 addresses,0.0.0.0/0,::/0, an IPv4-mapped base, a zone identifier, surrounding blanks.isClientIpAllowedadmits no client through an unreadable entry, and still admits through the readable ones in the same list.allowedIpRangesholding an unreadable entry or a non-string, naming each one, and accepts a well-formed list.packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts, a new top-level describe on the existing harness (the realAuthPlugin, the realSettingsServicewith the shipped auth manifest, the real settings routes):PUT /api/settings/auth) refuses a list holding an unreadable entry, alone or beside an entry matching the saving address:400,error.codeSETTINGS_VALIDATION, fieldallowed_ip_rangeswithcodeinvalid_valueandconstraint{ invalidEntries: [...] }, the message naming the entry, nothing stored.400,error.codeand fieldcodeas the first case.SettingsService.setis refused with the sameSETTINGS_VALIDATION/invalid_value/constraint.allowedIpRangesholding one refuses the boot, naming it.OS_AUTH_ALLOWED_IP_RANGESvalue, holding one: the gate admits nobody through it, the readable entry still admits, and the boot logs aterrornaming it./32,/128, bare-address and two-digit-prefix entries saves and gates as before.packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts, a second new top-level describe on the same harness, for a value that is not text (['10.1.2.3'], a two-entry list,[], a number,true, an object):400,error.codeSETTINGS_VALIDATION, fieldallowed_ip_rangeswithcodeinvalid_valueandconstraint{ type: 'textarea' }, nothing stored. The same holds whatever address the save comes from.SettingsService.setof it is refused with the sameSETTINGS_VALIDATION/invalid_value/constraint.error, and a save of the list as text replaces it and gates as written.allowed_ip_ranges(no hit forallowed_ip_ranges,allowedIpRanges,isClientIpAllowedorIP_NOT_ALLOWEDunderpackages/qa).Acceptance notes
/0is kept.0.0.0.0/0admits every IPv4 address and::/0every IPv6 address, as before; each still refuses the other family and a request whose address cannot be established, so it is not the same as an empty list.203.0.113.5/24keeps reading as203.0.113.0/24(at/0too). Refusing them would change the meaning of entries that save today./08is/8;/032is/32on an IPv6 base). A three-digit IPv4 prefix is refused.::ffff:203.0.113.0/24). An IPv6-sized prefix on it (/120) is refused.applyConfigPatch({ allowedIpRanges })does not refuse. There a refusal would keep the standing list in force instead of the one the settings binding applies, and the standing list can admit addresses the stored one does not. The entry matches nothing at the gate instead, and the binding names it. The declared list (plugin options,auth:configure) is refused at construction. The method's docblock now says so, and that it logs nothing itself.allowed_ip_rangesas atextarea, so the save accepts text or a clear (null) and nothing else, an empty list[]included: no other form is read anywhere. The refusal'sconstraintis{ type: 'textarea' }, the specifier type the value must take, spelled as the service's own format refusals spell it. A stored value of another form is applied closed rather than read as entries, because no door declares how such a value reads.OS_AUTH_ALLOWED_IP_RANGEScannot produce one: the service reads it as text, since the key declares no default to coerce by.PUT /api/settings/authand every in-processSettingsService.set/setManyreach the write predicate.sys_settingandsys_platform_settingare read-only on the data API (apiMethods: ['get', 'list']), so no other API writes the row.OS_AUTH_ALLOWED_IP_RANGESis read, not saved: the gate and the binding's log hold for it. No spec or stack key carries the list (packages/spec/srcnamesallowed_ip_rangesonly in theserver.trustProxydescription); a host'sallowedIpRangesreachesAuthManageronly through its constructor.isLoopbackIssuer's127.0.0.0/8), all well-formed, and read as before.ipMatchesRangestays the one published name;parseIpRange,unreadableIpRangeEntries,assertAllowedIpRangesandreadAllowedIpRangesare module helpers, not added to the package entry.main. The branch mergedorigin/main4564d6fa5b. The one conflict, in the settings binding ofauth-plugin.ts, was resolved by keeping both sides:main's restore of a cleared setting to the deployment's declared value through the shared restore arms, and this change's reading and report of the applied value.origin/main9b24f3ca87, which had moved the refusal copy ofallowed-ip-ranges.tsto every language on the platform locale table and made the lock-out recovery four steps. Three conflicts, each resolved by keeping both sides: the copy table carries this change's two refusals (a value that is not text, an entry that names no range) in all eleven languages, whichmain'ssettings-copy-locale.test.tsholds sentence by sentence; the pin file keeps both new top-level describes; the hardening guide's Network row keeps the IPv4 or IPv6 wording andmain's recovery. The changeset's lock-out step now clears the stored value, since a clear is the one write an env override lets through.test:repowas not run locally: no test in that project changed. CI's Test Core runs it.Verification
Readings at head
03655cf8e1(the branch merged withorigin/main9b24f3ca87, then the changeset's lock-out step) unless another tree is named.Premise: the new pins on the trees before them
da71a1fb9a), source still atb3d2f4bdb7:vitest run src/allowed-ip-ranges.pin.test.ts src/auth-manager.test.ts -t allowgaveTests 11 failed | 21 passed. Every new refusal pin was red there; every well-formed control and the preservation save were green.d28f95ba40), source still at the merge2a06416210: both files gaveTests 4 failed | 347 passed (351). The four not-text pins were red; the ordering case with its addederror.codeand fieldcodeassertions was green, as it needs no code change.Package tests and typecheck
pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2(the whole package, under the verify lock):Test Files 228 passed (228),Tests 4105 passed | 10 skipped (4115), exit 0. The files includesettings-copy-locale.test.ts, which holds every sentence of the refusal copy, this change's two included, in every language on the platform locale table.pnpm --filter @objectstack/plugin-auth typecheck: exit 0,check:test-typecheck: OK(10 files, 88 errors, 23 pinned signatures held; the ledger is unchanged).@objectstack/plugin-authdirectly (cli,client,organizations,plugin-dev,dogfood,http-conformance,runtime,service-automation,service-sms,verify),turbo run typecheck --onlyover their built closure:Tasks: 10 successful, 10 total, exit 0. The package entry (index.ts) is not in the diff, so nothing further downstream sees a type move.VERDICT command-exit 0.Ablation: each refusal, on committed files
Run at
4b2c6130c0, before the second merge ofmain. The merge leftauth-manager.tsandauth-plugin.tsbyte-identical, and inallowed-ip-ranges.tschanged only the copy table and the predicate's copy lookup, not the conditions the legs mutate.node scripts/ablation-replace.mjsin wrap mode, one locked run of both test files per leg. Each mutation landed (anchor count 1 to 0, blob changed) and the tool restored it (blob equal to HEAD,git diff HEADempty). These tests importsrc/directly, so nodist/leg applies.assertAllowedIpRangescall removedAfter the nine legs the tree had 0 changed paths.
Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 95 commands (8 paths against merge base9b24f3ca8). All 95 ran at this head, each with stdin closed, and each exited 0. Ten first refused for want of built output in this worktree (nine with exit 3,check:docswith exit 1 on a missing generated schema tree); they were run again after the closure build, or aftercheck:type-check-debt's own full build forcheck:skill-examplesandcheck:dual-build-cjs-loads, and the code recorded is that run's.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran:95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.Dogfood battery
pnpm --filter @objectstack/dogfood testat this head, under the verify lock, no shard, afterturbo run build --filter=@objectstack/dogfoodwith the consumers above (Tasks: 69 successful, 69 total):Test Files 423 passed | 3 skipped (426),Tests 3532 passed | 41 skipped (3573), exit 0.CI at this head
50 check-runs, read once their runs completed: 38 success, 12 skipped, none failed. Required contexts:
Lint & Repo Gates,TypeScript Type Check,Test Core(8 of 8 shards),Build Core,Governed Surface Queue Guard,Test UIandLive E2E UIsuccess;Lint UI,Type Check UIandBuild & E2E UIskipped (the paths select no UI lane).NOT MEASURED
test:repolocally: no test in that project changed. CI'sTest Coreruns it, green above.main's push battery only), Build Docs, and the type-check lanes. CI's own, not run here.4b2c6130c0above.origin/mainpast9b24f3ca87: 24 commits at reading time, none touchingpackages/plugins/plugin-auth. GitHub reads the PRmergeable: clean, so it was not merged again.🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg