Skip to content

fix(plugin-auth)!: an allowed IP range that names no range is refused at save and never matches at the gate - #30717

Draft
objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/sec-hardening-r8
Draft

objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/sec-hardening-r8

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: updated here — content/docs/permissions/authentication.mdx (the allowed_ip_ranges row, and the first two rules under "Saving, clearing and lockout recovery") and docs/HARDENING.md (the Network row); skills/** teaches no allowed_ip_ranges

维护者速读

  • 改了什么:auth.allowed_ip_ranges(IP 白名单)的每一项只剩一种读法:一个地址,或「地址 / 前缀长度」,前缀只能是十进制数字且落在本地址族范围内(IPv4 为 0 到 32,IPv6 为 0 到 128)。设置值本身必须是文本(每行一项或逗号分隔),列表、数字、对象等非文本值不构成任何列表。登录网关、设置保存检查、部署代码里声明的列表三处共用这一读法。
  • 为什么改:读不懂的条目或取值应当在保存时就被拒收,在网关处不放行任何地址;三处此前没有共用一条严格的规则。
  • 风险与回滚:写法规范的条目含义不变。含不规范条目的部署:在设置里保存会被拒(400),代码里声明会在启动时报错,已存的或环境变量里的不规范条目不再放行任何人,并在启动时以 error 日志点名该条目。已存的非文本取值不再放行任何地址,启动时以 error 日志说明并给出修法。回滚即 revert 本 PR。
  • 先看: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, parseIpRange in auth-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, and readAllowedIpRanges in allowed-ip-ranges.ts is the one reading of a whole value: a value that is not text (a list, a number, an object) names no list.

  • Sign-in gate (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.
  • Settings save (allowedIpRangesWritePredicate): a value that is not text is refused first, with 400 SETTINGS_VALIDATION, invalid_value at allowed_ip_ranges, constraint: { type: 'textarea' } and a message in the caller's language. A list holding an entry that names no range is refused with 400 SETTINGS_VALIDATION, invalid_value at allowed_ip_ranges, the entries in constraint.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's PUT /api/settings/auth and in-process SettingsService writes alike.
  • Declared list (AuthManager constructor, assertAllowedIpRanges): an allowedIpRanges from the plugin options or an auth:configure handler that holds one, or is not an array, is refused at construction, naming each entry.
  • Applied list (bindAuthSettings): a stored list or an OS_AUTH_ALLOWED_IP_RANGES value holding one is applied (the entry matches nothing), and the binding logs one error per 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 one error per 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.
  • Docs: the authentication guide's allowed_ip_ranges row and its saving rules, and the hardening guide's Network row.

Tests

  • packages/plugins/plugin-auth/src/auth-manager.test.ts, in the existing IP allow-list (ADR-0069 D5) describe:
    • ipMatchesRange matches 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).
    • Well-formed controls keep their reading: IPv4 and IPv6 CIDR, /32, /128, bare IPv4 and IPv6 addresses, 0.0.0.0/0, ::/0, an IPv4-mapped base, a zone identifier, surrounding blanks.
    • isClientIpAllowed admits no client through an unreadable entry, and still admits through the readable ones in the same list.
    • The constructor refuses a declared allowedIpRanges holding an unreadable entry or a non-string, naming each one, and accepts a well-formed list.
    • The former case "a prefix the IPv6 family does not read leaves the base address alone" is replaced by the rule above: such an entry now matches nothing.
  • packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts, a new top-level describe on the existing harness (the real AuthPlugin, the real SettingsService with the shipped auth manifest, the real settings routes):
    • The console save (PUT /api/settings/auth) refuses a list holding an unreadable entry, alone or beside an entry matching the saving address: 400, error.code SETTINGS_VALIDATION, field allowed_ip_ranges with code invalid_value and constraint { invalidEntries: [...] }, the message naming the entry, nothing stored.
    • The entry refusal comes first, whatever address the save comes from (including an address the server cannot establish), with the same 400, error.code and field code as the first case.
    • An in-process SettingsService.set is refused with the same SETTINGS_VALIDATION / invalid_value / constraint.
    • A declared allowedIpRanges holding one refuses the boot, naming it.
    • A list stored before the save check, and an OS_AUTH_ALLOWED_IP_RANGES value, holding one: the gate admits nobody through it, the readable entry still admits, and the boot logs at error naming it.
    • Preservation: a list of well-formed IPv4, IPv6, /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):
    • The console save refuses it, saved from an address it spells: 400, error.code SETTINGS_VALIDATION, field allowed_ip_ranges with code invalid_value and constraint { type: 'textarea' }, nothing stored. The same holds whatever address the save comes from.
    • An in-process SettingsService.set of it is refused with the same SETTINGS_VALIDATION / invalid_value / constraint.
    • Stored before the save check (a list, a number, an object): the gate admits no client, the boot logs at error, and a save of the list as text replaces it and gates as written.
  • No dogfood pin: no dogfood harness covers allowed_ip_ranges (no hit for allowed_ip_ranges, allowedIpRanges, isClientIpAllowed or IP_NOT_ALLOWED under packages/qa).

Acceptance notes

  • /0 is kept. 0.0.0.0/0 admits every IPv4 address and ::/0 every 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.
  • Bits past the prefix are not refused. 203.0.113.5/24 keeps reading as 203.0.113.0/24 (at /0 too). Refusing them would change the meaning of entries that save today.
  • Leading zeros inside the digit count read decimal, as before (/08 is /8; /032 is /32 on an IPv6 base). A three-digit IPv4 prefix is refused.
  • An IPv4-mapped base takes an IPv4 prefix, as before (::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.
  • The value's form is the textarea's. The manifest declares allowed_ip_ranges as a textarea, so the save accepts text or a clear (null) and nothing else, an empty list [] included: no other form is read anywhere. The refusal's constraint is { 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_RANGES cannot produce one: the service reads it as text, since the key declares no default to coerce by.
  • The save doors, enumerated. The settings PUT /api/settings/auth and every in-process SettingsService.set/setMany reach the write predicate. sys_setting and sys_platform_setting are read-only on the data API (apiMethods: ['get', 'list']), so no other API writes the row. OS_AUTH_ALLOWED_IP_RANGES is read, not saved: the gate and the binding's log hold for it. No spec or stack key carries the list (packages/spec/src names allowed_ip_ranges only in the server.trustProxy description); a host's allowedIpRanges reaches AuthManager only through its constructor.
  • The matcher's other callers pass constants (the plain-HTTP private/loopback list and isLoopbackIssuer's 127.0.0.0/8), all well-formed, and read as before.
  • ipMatchesRange stays the one published name; parseIpRange, unreadableIpRangeEntries, assertAllowedIpRanges and readAllowedIpRanges are module helpers, not added to the package entry.
  • The merges of main. The branch merged origin/main 4564d6fa5b. The one conflict, in the settings binding of auth-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.
  • It then merged origin/main 9b24f3ca87, which had moved the refusal copy of allowed-ip-ranges.ts to 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, which main's settings-copy-locale.test.ts holds sentence by sentence; the pin file keeps both new top-level describes; the hardening guide's Network row keeps the IPv4 or IPv6 wording and main'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:repo was not run locally: no test in that project changed. CI's Test Core runs it.

Verification

Readings at head 03655cf8e1 (the branch merged with origin/main 9b24f3ca87, then the changeset's lock-out step) unless another tree is named.

Premise: the new pins on the trees before them
  • Entry rule: tests committed first (da71a1fb9a), source still at b3d2f4bdb7: vitest run src/allowed-ip-ranges.pin.test.ts src/auth-manager.test.ts -t allow gave Tests 11 failed | 21 passed. Every new refusal pin was red there; every well-formed control and the preservation save were green.
  • Value rule: tests committed first (d28f95ba40), source still at the merge 2a06416210: both files gave Tests 4 failed | 347 passed (351). The four not-text pins were red; the ordering case with its added error.code and field code assertions 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 include settings-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).
  • Consumers, downstream direction: the ten packages that depend on @objectstack/plugin-auth directly (cli, client, organizations, plugin-dev, dogfood, http-conformance, runtime, service-automation, service-sms, verify), turbo run typecheck --only over 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.
  • The one locked hold that ran these, the gates below that need a full build, and the dogfood battery: VERDICT command-exit 0.
Ablation: each refusal, on committed files

Run at 4b2c6130c0, before the second merge of main. The merge left auth-manager.ts and auth-plugin.ts byte-identical, and in allowed-ip-ranges.ts changed only the copy table and the predicate's copy lookup, not the conditions the legs mutate.

node scripts/ablation-replace.mjs in 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 HEAD empty). These tests import src/ directly, so no dist/ leg applies.

Leg Mutation Tests red
A the prefix-digit patterns accept any text 9
B the prefix-range bound removed 5
C the blank-inside-an-entry refusal removed 1
D the save predicate's entry refusal never fires 3
E the constructor's assertAllowedIpRanges call removed 2
F the settings binding's report removed 3
G the save predicate's not-text refusal never fires 3
H a stored value that is not text read as no list 1
I the binding's not-text report never logs 1

After the nine legs the tree had 0 changed paths.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 95 commands (8 paths against merge base 9b24f3ca8). 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:docs with exit 1 on a missing generated schema tree); they were run again after the closure build, or after check:type-check-debt's own full build for check:skill-examples and check: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 test at this head, under the verify lock, no shard, after turbo run build --filter=@objectstack/dogfood with 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 UI and Live E2E UI success; Lint UI, Type Check UI and Build & E2E UI skipped (the paths select no UI lane).

NOT MEASURED
  • test:repo locally: no test in that project changed. CI's Test Core runs it, green above.
  • The CI jobs the gate derivation names outside its list: the dogfood shards (merge queue only), Dogfood Verify CLI and Temporal Conformance (main's push battery only), Build Docs, and the type-check lanes. CI's own, not run here.
  • The ablation at this head: its readings are the ones at 4b2c6130c0 above.
  • origin/main past 9b24f3ca87: 24 commits at reading time, none touching packages/plugins/plugin-auth. GitHub reads the PR mergeable: clean, so it was not merged again.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg

…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ad6e705bc7e5cdb6b03ca40ed8279f2783574620
Local-runs: probe — the head's pure entry parser and matcher (auth-manager.ts lines 492-523 and 1170-1320, extracted verbatim by line into a scratch file outside the repository, blob e3693b0b82) run under node 22 against 48 corner inputs the pins do not name; every reading held
Scope: packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/allowed-ip-ranges.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-manager.test.ts, packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts + packages/plugins/plugin-auth/src/client-address.ts (resolveClientAddress, firstHeader), packages/plugins/plugin-auth/src/index.ts (the by-name export list), packages/services/service-settings/src/settings-service.ts (setMany, refuseByWritePredicates, validatePatch, firstPatternMiss), packages/services/service-settings/src/settings-service.types.ts (SettingsWritePredicateInput, SettingsWriteRefusal, SettingsValidationError), packages/services/service-settings/src/settings-routes.ts (the SettingsValidationError mapping), packages/services/service-settings/src/manifests/auth.manifest.ts (allowed_ip_ranges), packages/platform-objects/src/system/sys-setting.object.ts and sys-platform-setting.object.ts (apiMethods), docs/adr/0069-authentication-settings.md (D5). Prose faces (.changeset/allowed-ip-range-entry-rule.md, content/docs/permissions/authentication.mdx, docs/HARDENING.md) are the lane seat's ACCEPT at TIER_DEFAULT; their sentences were read against the code here because the changeset and the guide state the same rule the module headers state.

Face: the paths decide no face (no published schema, no governed register row; docs/HARDENING.md is not docs/adr/**); the committed changeset's line-initial Clause-②: no (narrowing) is the closed-pair spelling, so the face is narrowing and the tier is CONTRACT_REVIEW_TIER with ④ (record-recognisers --paths answers exactly this: no path face, the changeset's Clause-②: line decides). Rule under review: an auth.allowed_ip_ranges entry has one reading, shared by every door that judges or saves the list — an address, or an address, / and a prefix length in decimal digits within its family — and an entry that names no range is refused where it is saved or declared and matches no address at the gate. Full review (no prior at-tier record on any head; no Contract-review-claim: or hold on the PR; no review or comment on the PR at review time).

① Derived judgments

  1. Accept set narrowed, correctly stated. parseIpRange (auth-manager.ts 1243-1259) is the one reading: typeof string, trimmed, no blank inside, base read by readMatchedAddress (IPv4 dotted-quad; IPv6 literal lowercased, zone identifier dropped, IPv4-mapped form folded to its IPv4 address), then either no /, or a prefix matching ^[0-9]{1,2}$ (IPv4 base) / ^[0-9]{1,3}$ (IPv6 base) read with parseInt(…, 10) and held to the width. The first / splits, so a second / lands in the prefix and is refused; ASCII digits only; $ is end-of-string in JavaScript. Every other value reads null. Held, and the body's statement of the rule matches the code. Correct.
  2. Every caller of the parser, enumerated independently (git grep on the head tree, non-test sources). ipMatchesRange is the only consumer of parseIpRange for a match, and its callers are: (a) the sign-in gate, AuthManager.isClientIpAllowed (6103-6107), called from the ${basePath}/* middleware at auth-plugin.ts 3296 with the address resolveClientAddress established; (b) the settings save predicate, allowedIpRangesWritePredicate (allowed-ip-ranges.ts 203-230), on the address leg; (c) the plain-HTTP private/loopback host list, isPrivateOrLoopbackHostLiteral (auth-manager.ts 553-560), which passes five constant CIDRs and only after ipv4ToInt(bare) !== null; (d) isLoopbackIssuer (auth-plugin.ts 408-417), one constant 127.0.0.0/8 against a WHATWG-canonical hostname. unreadableIpRangeEntries is called by the save predicate, by assertAllowedIpRanges, and by the binding's reportUnreadableAllowListEntries. (c) and (d) pass well-formed constants and an address or hostname, so their verdicts are unchanged (probed: 127.0.0.2 in, localhost out, as before). No other caller exists. Correct.
  3. Every door that saves or reads the list, enumerated independently. Writes: PUT /api/settings/auth → setMany; in-process SettingsService.set → setMany (2535); setMany → validatePatch → refuseByWritePredicates (2655), which asks the registered predicate on every write that changes the key, HTTP or not, and throws before any row is written; the predicate now judges the entries before if (!transport), so the in-process leg reaches the entry refusal. sys_setting and sys_platform_setting carry apiMethods: ['get', 'list'], so the data API opens no second write door. OS_AUTH_ALLOWED_IP_RANGES is read by the settings service as a locked env source, never written. Reads: bindAuthSettings (auth-plugin.ts 2403-2411) applies the stored or env list through parseAllowedIpRanges and now reports its unreadable entries; the declared list (plugin options, or an auth:configure handler — ctx.trigger('auth:configure', authConfig) at 764 precedes new AuthManager(authConfig) at 809) reaches assertAllowedIpRanges in the constructor (1784). AuthManager is constructed nowhere else in non-test source (a test-support helper and a script fixture aside). Covered: every door the rule names. Correct.
  4. No well-formed entry changed meaning. Probed and pinned: IPv4 and IPv6 CIDR, /32, /128, bare addresses, 0.0.0.0/0 and ::/0 (each admitting its own family only, an IPv4-mapped client reading as IPv4 and so admitted by an IPv4 range, not by ::/0 — as before), leading zeros inside the digit count read decimal, bits past the prefix not judged, an IPv4-mapped base (dotted or hex) taking an IPv4 prefix, a zone identifier on either side dropped, surrounding blanks and CRLF trimmed, upper-case hex folded. Dotted-quad octets with leading zeros still read decimal (unchanged, both sides, the client side never carries them). What changed meaning is exactly the set the changeset's BREAKING banner names: an entry whose prefix is not decimal digits within its family, or whose text is not an address, now admits nobody. That is the narrowing, declared. Correct.
  5. The forwarded-address path can no longer produce a non-address match. With trustProxy on, resolveClientAddress hands the gate the first comma-separated hop of the first forwarded header present (client-address.ts 61-90), unvalidated. The matcher now requires readMatchedAddress(ip) to read an address AND parseIpRange(range) to read a range before any comparison, and the only comparison left is the family's prefix comparison (ipMatchesRange 1308-1320). So a forwarded value that is not an address matches no entry under any list, and an entry that is not an address matches no client, its own spelling included (pinned at the unit level for both; probed for bracketed, blank-padded, empty and non-string clients). An undetermined address stays refused by isClientIpAllowed before the matcher. Correct, and fail-closed.
  6. IPv6 and IPv4-mapped corners. parseIpv6Literal (492-523) accepts only [0-9a-f:.], one ::, 1-4 hex digits per group, an embedded dotted-quad only in the last position, exactly eight groups after elision; readMatchedAddress lowercases and drops the zone first, so 2001:DB8::/32 and fe80::%eth0/10 read, while fe80::/10%eth0 (zone after the prefix) and a bracketed literal do not. An IPv4-mapped base, dotted or hex, is an IPv4 address and takes an IPv4 prefix; an IPv6-sized prefix on it is refused (pinned, probed). ::ffff:0.0.0.0/0 is a well-formed 0.0.0.0/0. Correct and consistent with the rule as the body states it.
  7. Fail-closed at bind. A stored or env-pinned list holding an unreadable entry is applied with the entry in it; the entry matches nothing, so the gate admits only what the other entries admit, and a list of only such entries admits nobody (recovery is the env override, named in the log line and the guide). The alternative — refusing the patch at applyConfigPatch — would keep the standing (declared or empty) list in force, which can admit more than the stored one, so the dev's choice is the closed side. reportUnreadableAllowListEntries (3139-3159) logs once per distinct list at error with consequence and fix on the first line: the right level under the degradation rule, since the console shows the list as in force. The declared list is refused at construction (boot refuses), the loud side for a deployment's own code. Correct.
  8. Envelope. The predicate returns { message, constraint: { invalidEntries } }; refuseByWritePredicates wraps it as FieldError { field: 'allowed_ip_ranges', code: 'invalid_value', message, label, constraint } (2905-2915) and throws SettingsValidationError (code: 'SETTINGS_VALIDATION', fields); the route maps it to 400 SETTINGS_VALIDATION with details.fields (settings-routes.ts 390-400). No new code, no new field-error member (ADR-0112, ADR-0114); invalid_value is the member the service already uses for every predicate refusal; constraint is a declared Record<string, unknown>. AllowedIpRangesWriteRefusal.constraint becomes a union — allowed-ip-ranges.ts is not re-exported from the package entry, so no published type moves. Correct.
  9. Refusal pins, code and status (ADR-0112). Console save, first case (allowed-ip-ranges.pin.test.ts 398-412): status 400, error.code SETTINGS_VALIDATION, field code invalid_value, constraint, message, nothing stored — complete. In-process case (423-431): err.code and fields[0] (field, code, constraint), nothing stored — complete for a door that has no HTTP status. Ordering case (414-421): status 400 and fields[0].constraint, no error.code assertion — the checklist requires both on every refusal case; the unique constraint shape does discriminate the refusal, but the code is the envelope's discriminator and is owed. must-fix, see ④. The construction refusal is a thrown Error matched on its text, the shape of its sibling assertTrustedAppSchemes; the gate pins are boolean on isClientIpAllowed, the 403 IP_NOT_ALLOWED envelope being pinned on main already. Otherwise correct.
  10. Tests are rule tests and the ablation reads. Unit: 24 unreadable entries across both families against the base, an inside and an outside client, a mapped client; four text entries; the well-formed control table; the gate through a mixed list; the constructor on a bad entry, a non-string, and a good list. Pin file: a new top-level describe in the existing harness (real AuthPlugin, real SettingsService with the shipped manifest, real settings routes), covering both save doors, the declared list, a pre-existing stored list, the env list, and preservation. Body: tests committed first and red on the merge base (11 failed | 21 passed, named to da71a1fb9a over source b3d2f4bdb7); six ablation legs on committed files, each red on the pins it should red and restored to a clean tree; package suite 4055 passed | 10 skipped; typecheck exit 0, ledger unchanged. Readings are the dev's, named to this head; read-only here apart from the one probe on the Local-runs line.
  11. Public surface. ipMatchesRange keeps its name and signature (a typeof ip guard added); parseIpRange, unreadableIpRangeEntries, assertAllowedIpRanges, IpRange are module exports not in the by-name list index.ts publishes from auth-manager.ts (lines 16-35), and allowed-ip-ranges.ts is not exported at all. No widening; no (narrowing) is the right arm. Correct.

② Semver level

Changeset .changeset/allowed-ip-range-entry-rule.md: @objectstack/plugin-auth: patch, title with !, a BREAKING banner naming who moves and the fix, line-initial Clause-②: no (narrowing) and Metadata-protocol: none (no authorable key changes; the setting's value domain is a runtime rule, not a spec key — no allowedIpRanges in packages/spec/src), exactly one ADR-0087 marker, not-required (no-migration-prescription), a category the gate lists, with a reason that holds (no stored row is rewritten; a bad row is refused or inert). AGENTS.md step 3: a non-protocol narrowing ships patch and keeps its BREAKING banner and disposition. The PR body carries the same declaration lines at line start, and Skills-docs: updated here — …; skills/** teaches no allowed_ip_ranges is present in both and holds (0 hits under skills/ on the head tree). Touched packages: @objectstack/plugin-auth (published, changeset present); service-settings and platform-objects are read, not changed. Consistent.

③ Boundary flags

  • Author objectstack-fleet[bot] (relay); no other login to name. Draft, base main, head repo equals base repo, auto_merge null, no governed path in the file list (register read at GOVERNED_SURFACES), no closing keyword in the body (a security-seat PR with no card).
  • CI at head ad6e705bc7: NOT MEASURED — at review time the head carries 0 workflow runs and five queued check-suites (the head was pushed at 21:39Z and the PR reads mergeable_state: dirty, the dev merging main). Nothing here reads as green; the landing head owes all checks green and, for a (narrowing) PR, a dogfood_battery reading pinned to it — the body itself records the battery as NOT MEASURED and running.
  • PR body: rule-level. It states the entry rule, the four doors, the kept readings and the envelope; it names no former behaviour beyond the replaced IPv6 exact-base case already public in main's test name and docblock. Test names state the rule; the test data are the rule's boundary inputs. Commit titles and the changeset are rule-level. No leakage beyond the rule found.
  • Acceptance notes, each answered: /0 kept, own family only — held; bits past the prefix not refused — held and pinned; leading zeros within the digit count decimal — held; mapped base takes an IPv4 prefix — held; applyConfigPatch does not refuse, the binding reports — held, and the closed side (① item 7); save doors enumerated — held (① item 3); other callers pass constants — held (① item 2); nothing new on the package entry — held (① item 11); test:repo not run locally — CI's business.
  • Prose faces, read against the code (for the lane seat): the guide's new rule paragraph and the amended "no address is checked for them" sentence hold; the allowed_ip_ranges row and the hardening row now say IPv4 or IPv6, which ipv6PrefixEqual already delivered on main. The changeset's "Saving the list already in force again is not judged, as before" holds (refuseByWritePredicates skips an unchanged key).
  • Should (not blocking): the settings door's reading of a value that is not text is outside this rule and predates it; recorded for the seat in the local note, not here. Should: applyConfigPatch called by a host with an unreadable entry applies it closed with no line of its own (the binding is the only reporter) — a sentence on the method's docblock, or the report moved beside the patch, would keep "absence must be loud" at that door too. Should (docs-only, Tier H): ADR-0069 D5's note could record the entry rule. Nit: the manifest help text and its four translations describe the field without the prefix rule; the refusal message carries it.

④ Design judgment

On the narrowing face the narrowing is judged against what main declares. ADR-0069 D5 declares the allow-list as CIDR ranges judged by a gate before better-auth, and ADR-0049's hard constraint that a setting ships with the code path that enforces it; the main matcher's own docblock declared "an IPv4 CIDR, an IPv6 CIDR, or one exact address" and the manifest's help text "CIDR ranges or exact IPs". The diff takes nothing away from that declaration: every entry the declaration describes keeps its reading (① item 4, probed), and what it removes are readings the declaration never promised. The design is one parser at the one module every door already imported (ipMatchesRange was already "one matcher for every door"), so a list cannot be saved under one rule and enforced under another; refusal where a human or a deployment writes the list (save, declaration), inert-and-loud where nobody can be answered (a stored or env list at bind), and the closed side chosen at the one door where a refusal would have widened (applyConfigPatch). The envelope reuses the service's own predicate shape and an ADR-0114 member, and the entries are judged before the address so the entry refusal is reached whatever address the save comes from. The tests reach every door through the real plugin, service and routes, and the pure parser was probed here on the corners the pins do not name. The design holds.

One must-fix, in a file under review:

  • must-fix — packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts lines 414-421 (the case "the console save names the unreadable entry first, whatever address it is saved from"): a refusal case asserts both status and code (ADR-0112 envelope; review checklist); this one asserts status and constraint only. Add the error.code (SETTINGS_VALIDATION) and the field code (invalid_value) assertions the first case on the same door already makes. No code change is owed.

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 main the PR is taking.

Implemented-by: claude/sec-hardening-r8
Reviewed-by: session_01L6bhgVfjk2i8T2D4RmxsKg

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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 17 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/deployment/self-hosting.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/deployment/tenancy-modes.mdx (via AuthPlugin (symbol, a top-level class))
  • 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), AuthPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class), AuthPlugin (symbol, a top-level class))
  • content/docs/permissions/record-view-auditing.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/plugins/index.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/upgrading.mdx (via AuthPlugin (symbol, a top-level class))

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

  • content/docs/releases/v17/17-3.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/releases/v18/18-0.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/releases/v18/18-1.mdx (via AuthPlugin (symbol, a top-level class))
  • content/docs/releases/v18/18-2.mdx (via AuthManager (symbol, a top-level class), AuthPlugin (symbol, a top-level class))
  • content/docs/releases/v18/18-3.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/releases/v18/index.mdx (via AuthPlugin (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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 221 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 9b24f3ca8734b5e0ea77cc3cb282c3f8f55a862b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 080f5dce84e2f6fc03840aea99060da57fc1bc5f — the merge of head 03655cf8e198cb56d9ca9e3df2f8510f2a6ad2b4 into base 9b24f3ca8734b5e0ea77cc3cb282c3f8f55a862b, 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 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

⚠️ 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 9b24f3ca8734b5e0ea77cc3cb282c3f8f55a862b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 03655cf8e198cb56d9ca9e3df2f8510f2a6ad2b4
Local-runs: none
Scope: packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/allowed-ip-ranges.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-manager.test.ts, packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts + packages/plugins/plugin-auth/src/client-address.ts (resolveClientAddress, firstHeader), packages/plugins/plugin-auth/src/index.ts (the by-name export list) and packages/plugins/plugin-auth/package.json (exports), packages/plugins/plugin-auth/src/auth-settings-declared.ts (DECLARED_FIELDS, AUTH_SETTING_RESTORE, copyDeclared, captureDeclaredAuthOptions), packages/plugins/plugin-auth/src/settings-copy-locale.ts and settings-copy-locale.test.ts, packages/spec/src/system/platform-locales.ts (PLATFORM_LOCALES), packages/services/service-settings/src/settings-service.ts (setMany, validatePatch, refuseByWritePredicates, effectiveEnvOverride, coerceEnvValue), packages/services/service-settings/src/manifests/auth.manifest.ts (allowed_ip_ranges), docs/adr/0069-enterprise-authentication-hardening.md (D5), scripts/pm/check-governed-merges.mjs (GOVERNED_SURFACES). Prose faces (.changeset/allowed-ip-range-entry-rule.md, content/docs/permissions/authentication.mdx, docs/HARDENING.md) are the lane seat's ACCEPT at TIER_DEFAULT; each new sentence was read against the code here.

Face: record-recognisers --paths on the eight changed paths answers that the paths decide no face (no published schema, no governed register row: docs/HARDENING.md is prose, matching neither docs/adr/** nor docs/NORTH-STAR.md, the register's only docs/ rows) and defers to the committed changeset's Clause-②: line, which carries no (narrowing), so the face is narrowing, the tier CONTRACT_REVIEW_TIER with ④, and the floor is every path of the diff outside the prose faces. Full review of this head: the prior at-tier record (comment 6672830867) is a FAIL at ad6e705bc7; since then the branch carries the test-assertion fix it owed, a second rule (a value that is not text) in the same family, and two merges of main with conflicts resolved in allowed-ip-ranges.ts, its pin test and the hardening guide. Rule under review, as the seat post records it: an allowed IP range entry is an address or an address with a decimal prefix within its family, saved only as text; anything else is refused at save and matches no address at the gate.

① Derived judgments

  1. The entry rule, one reading. parseIpRange (auth-manager.ts 1243-1259) is the only reading of an entry: a string, trimmed, no blank inside, a base readMatchedAddress reads (dotted-quad IPv4; an IPv6 literal lowercased, zone identifier dropped, the IPv4-mapped form folded to its IPv4 address), then either no /, or a prefix matching ^[0-9]{1,2}$ on an IPv4 base and ^[0-9]{1,3}$ on an IPv6 base, read by parseInt(…, 10) and held to the width. The first / splits, so a second / lands in the prefix and fails the pattern; \d and [0-9] are ASCII; $ is end of string. Everything else reads null. Read against the corners the brief names: an empty or blank-only entry (trim then empty), a blank inside, a non-decimal prefix (0x18, +8, -0, 8.0, 1e1), a leading-zero prefix within the width (/08, /032: decimal, as the body states), an overlong prefix (/008 on IPv4, /0032 on IPv6, /129, /33), an IPv4-mapped base with an IPv6-sized prefix (::ffff:203.0.113.0/120, hex-spelled too: the base folds to IPv4 so the width is 32 and a three-digit prefix fails), a zone identifier before the / (dropped) and after it (fails the pattern), a bracketed literal (fails the IPv6 character class), a bare address of either family (bits = width), /0 on either family. Each reading is the rule's. Correct.
  2. The value rule, one reading. readAllowedIpRanges (allowed-ip-ranges.ts 97-101): null/undefined is no list; a string is its entries (parseAllowedIpRanges: split on newlines and commas, trimmed, blanks dropped); any other value is { ranges: [NOT_TEXT_ENTRY], notText }. NOT_TEXT_ENTRY is text with blanks inside, so parseIpRange reads it null and isClientIpAllowed (6113-6118), seeing a non-empty list, admits no address through it. The value is judged before the entries and before the address, so a value of the wrong form is refused whatever it spells. Correct, and the closed side.
  3. Every consumer of the parser, enumerated on the head tree (non-test sources). ipMatchesRange (1308-1320) is the one matcher, requiring parseIpRange(range) and readMatchedAddress(ip) both to read before any comparison; its callers: the sign-in gate isClientIpAllowed, called from the ${basePath}/* middleware (auth-plugin.ts 3429-3449) with the address resolveClientAddress established (client-address.ts: the first hop of the first forwarded header only under trustProxy, else the peer address, else undefined, which the gate refuses before the matcher); the save predicate's address leg; isPrivateOrLoopbackHostLiteral (auth-manager.ts 556-574), which passes the five constant plain-HTTP CIDRs, each well-formed, and only after ipv4ToInt(bare) !== null; isLoopbackIssuer (auth-plugin.ts 420-430), one constant 127.0.0.0/8 against a WHATWG hostname. unreadableIpRangeEntries is called by the predicate, by assertAllowedIpRanges and by the binding's reportUnreadableAllowList. The plain-HTTP eligibility list and the loopback-issuer check pass constants the rule reads unchanged, so their verdicts do not move. Correct.
  4. Every door that saves, declares or reads the list, each fails closed. Save: PUT /api/settings/auth and in-process SettingsService.set both reach setMany → validatePatch → refuseByWritePredicates (settings-service.ts 2694-2700, 2914-2962), which asks the registered predicate on every write that changes the key, HTTP or not, and throws SettingsValidationError before any row is written; validatePatch itself declares no form check for a textarea, so the predicate is the one gate for the value's form, and it runs on every write. sys_setting and sys_platform_setting are read-only on the data API (unchanged from the prior record's reading). Declared: ctx.trigger('auth:configure', authConfig) (781) precedes captureDeclaredAuthOptions (825) and new AuthManager(authConfig) (826), whose constructor calls assertAllowedIpRanges (1784): a non-array or an entry that names no range refuses the boot, naming each. The restore arm allowed_ip_ranges: { via: 'patch', field: 'allowedIpRanges' } hands back copyDeclared(declared.allowedIpRanges), the list the constructor already asserted. Stored and env: bindAuthSettings (2429-2433) applies readAllowedIpRanges(values.allowed_ip_ranges).ranges whenever the key's source is not default, and reportUnreadableAllowList (3260-3304) logs one error per distinct value or list, naming the fix, fromEnv deciding which fix. The env path cannot produce a value that is not text: effectiveEnvOverride coerces by reg.defaults.get(key) (1398, coerceEnvValue 4141-4153) and the manifest declares no default for allowed_ip_ranges (auth.manifest.ts 370-377), so the override is read as text. applyConfigPatch (2193) applies the patch as given and its docblock now says so (2183-2191), closing the prior record's should. Covered: every door the rule names, plus the declared list. Correct.
  5. No well-formed entry changed meaning. IPv4 and IPv6 CIDR, /32, /128, bare addresses of either family, 0.0.0.0/0 and ::/0 (own family only), leading zeros within the digit count read decimal, bits past the prefix not judged, an IPv4-mapped base taking an IPv4 prefix, a zone identifier dropped, surrounding blanks trimmed, upper-case hex folded: each pinned in the control case (auth-manager.test.ts) and the preservation save (allowed-ip-ranges.pin.test.ts). A dotted-quad octet with leading zeros still reads decimal on both sides (unchanged, not in this diff). What moves is exactly what the changeset's BREAKING banner names. Correct.
  6. The refusal envelope. Both new refusals return the predicate shape { message, constraint }; refuseByWritePredicates wraps each as a FieldError with code: 'invalid_value' and the key's label and throws SettingsValidationError (code: 'SETTINGS_VALIDATION'), which the route serves as 400. constraint is { type: 'textarea' } for the value's form (the specifier type the manifest declares, 371), { invalidEntries } for the entries. No new code, no new field-error member. AllowedIpRangesWriteRefusal.constraint becomes a three-way union in a module the package does not publish. Correct.
  7. The eleven-language copy. SettingsCopyLocale names eleven keys (en, zh-CN, zh-Hant, es-ES, ar, fr, pt-BR, ru, de, ja-JP, ko), one per row of PLATFORM_LOCALES; the merged table adds notText and unreadable(entries) to all eleven, each stating the same rule (an address, or an address, / and a prefix length in decimal digits, 0 to 32 for IPv4, 0 to 128 for IPv6, the same three examples) and the same consequence (not saved), in its own language. The table's type (a Record keyed by SettingsCopyLocale with AllowedIpRangesRefusalCopy values) makes a missing key or sentence a type error, and settings-copy-locale.test.ts holds one entry per platform tag, every sentence present and non-English for the ten translated keys, each tag and stored alias served its own entry, and zh-TW never the Simplified entry. The Arabic and Russian sentences keep the digit ranges in the Latin form the other entries use. Complete and correct.
  8. Refusal pins, code and status. Console save (first case): 400, error.code SETTINGS_VALIDATION, field code invalid_value, constraint, message, nothing stored. The ordering case (the prior record's must-fix) now asserts error.code and fields[0].code beside status and constraint. In-process: err.code, fields[0] field, code and constraint, nothing stored. Not-text: the same four assertions on the console door, the ordering case and the in-process door, plus the stored-value case asserting the gate closed, the error log and a text save replacing it. Construction: thrown text named. Complete.
  9. Tests are rule tests. Unit: twelve IPv4 and ten IPv6 unreadable entries, each against the base, an inside, an outside and a mapped client; four text entries against their own spelling; the control table; the gate through a mixed list; the constructor on a bad entry, a non-string, and a good list. Pin file: two new top-level describes on the existing harness (the real plugin, the real service with the shipped manifest, the real routes) covering both save doors, the declared list, a pre-existing stored list and value, the env list, and preservation. Readings are the dev's, named to their trees; read-only here.
  10. Public surface. index.ts publishes auth-manager.ts by name and lists ipMatchesRange alone of this family; parseIpRange, unreadableIpRangeEntries, assertAllowedIpRanges, IpRange are not listed; allowed-ip-ranges.ts is on no entry (package.json exports: ., ./rate-limit-storage, ./client-address). ipMatchesRange keeps its signature. No widening; no (narrowing) is the right arm. Correct.

② Semver level

.changeset/allowed-ip-range-entry-rule.md: @objectstack/plugin-auth: patch, a ! title, a BREAKING banner naming who moves and the fix (both rules), line-initial Clause-②: no (narrowing) and Metadata-protocol: none, and exactly one ADR-0087 marker, not-required (no-migration-prescription), with a reason that holds (no stored row is rewritten). Metadata-protocol: none holds: packages/spec/src names allowed_ip_ranges only inside the server.trustProxy description, and the setting's value domain is a runtime rule on a manifest key, not an authorable spec key. AGENTS.md Post-Task step 3: a non-protocol narrowing ships patch and keeps its banner and disposition. The PR body carries the same two declaration lines at line start, and Skills-docs: … skills/** teaches no allowed_ip_ranges holds (0 hits under skills/ and .claude/skills/ on the head tree). Touched published package: @objectstack/plugin-auth only. Consistent.

③ Boundary flags

  • Author objectstack-fleet[bot] (relay), draft, base main, head repo equals base repo, no governed path in the file list (register read), no card (a security-seat no-card order).
  • Check-runs at 03655cf8e1: 54, all completed, none failure. Required contexts: Lint & Repo Gates, TypeScript Type Check, Test Core and its eight shards, Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI success; Lint UI, Type Check UI, Build & E2E UI skipped (the paths select no UI lane). Dogfood Regression Gate, Check Changeset, Build Docs, Spec property liveness success. The body's CI paragraph matches.
  • The body's NOT MEASURED items, each answered: test:repo locally — Test Core is green at this head. The queue-only and push-only jobs — skipped at this head as the body says, CI's own. The ablation not re-run at this head — git diff 4b2c6130c0..03655cf8e1 under packages/plugins/plugin-auth/src leaves auth-manager.ts and auth-plugin.ts untouched and changes allowed-ip-ranges.ts only in the copy table and the lookup's name, so the legs' anchors and the conditions they mutate are the ones that ran. origin/main past the merge base — now 27 commits (cf478ea339): none under packages/plugins/plugin-auth; two under service-settings (the deployment notice, and a namespace read that lists the stored cascade beneath an env pin without moving value or source, the two fields the binding reads); setMany still validates and then asks the predicates in the same order. No re-merge owed for this review.
  • Acceptance notes, each held: /0 kept, own family only; bits past the prefix not refused; leading zeros within the digit count decimal; an IPv4-mapped base takes an IPv4 prefix; applyConfigPatch applies, the binding reports, the docblock says so; the value's form is the textarea's and the env door cannot produce another; the save doors enumerated; the matcher's other callers pass constants; nothing new on the package entry; the two merges of main and their conflict resolutions, read in the tree.
  • The prior record's must-fix is resolved (① item 8); its first should (the docblock) is resolved; its ADR-0069 D5 note and manifest-help-text items stand as docs-only follow-ups, not blocking.
  • PR body, commit titles, changeset and test names: rule-level, no leakage beyond the rule found.
  • Prose faces read against the code for the lane seat: the guide's two new rules state the parser's digit counts and ranges, both doors, the startup refusal, the startup error and the constraint shapes as the code has them; the amended "no address is checked for them" sentence holds; the allowed_ip_ranges row and the hardening row's "IPv4 or IPv6 CIDR, or exact addresses" hold; the hardening row's and the changeset's four-step recovery match main's clear-through-the-pin rule.
  • Should, not blocking: isClientIpAllowed reads allowedIpRanges as an array; a host that hands applyConfigPatch a non-array (a violation of the typed option, reachable by no save door; the constructor refuses it and the binding always hands an array) is neither refused nor read closed there. Nit: assertAllowedIpRanges names an entry with JSON.stringify, which throws its own error on a BigInt entry: still a refusal at construction, with a less helpful line.

④ Design judgment

On the narrowing face the narrowing is judged against what main declares. ADR-0069 D5 declares the allow-list as CIDR ranges judged by middleware before better-auth, under ADR-0049's constraint that a setting ships with the code path that enforces it; the manifest's help text says "CIDR ranges or exact IPs"; main's matcher docblock said an IPv4 CIDR, an IPv6 CIDR or one exact address. The diff takes nothing from that declaration: every entry it describes keeps its reading (① item 5), and what it removes are readings the declaration never promised. The design is one parser at the module every door already imported, so a list cannot be saved under one rule and enforced under another; a value's form is judged before its entries and both before the saving address, so the refusal is reached whatever address the save comes from and whoever writes; refusal where a human or a deployment writes the list (save, declaration), inert-and-loud where nobody can be answered (a stored or env value at bind, one error per distinct value with the fix on the line, fromEnv picking the right fix), and the closed side at the one door where a refusal would have widened (applyConfigPatch, now stated in its docblock). The value-type rule reuses the same shape: a value that is not text is handed to the gate as one entry that names no range, so the gate's own rule closes it with no second mechanism. The eleven-language copy is held sentence by sentence by a test on the platform locale table, so a twelfth language cannot land without its sentences. The pins reach every door through the real plugin, service and routes, and every refusal case asserts status, envelope code and field code. The earlier FAIL's must-fix is resolved in the tree. No must-fix found; the design holds.

Implemented-by: claude/sec-hardening-r8
Reviewed-by: session_01L9qibvynRbCtMndW8EHNGc

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc

席位核对

ACCEPT · 2026-11-06T08:04Z · head 03655cf8e198cb56d9ca9e3df2f8510f2a6ad2b4

  • Contract review: PASS at this head, CONTRACT_REVIEW_TIER, full review record 6680197584 (isolated reviewer; face narrowing; the earlier FAIL 6672830867's must-fix is resolved in the tree). No must-fix. One should-fix (a host-side applyConfigPatch with a non-array list is not read closed at the gate; no save door reaches it) and one nit (the boot refusal's message for an unprintable entry), plus two standing docs items: all carried on the security#1 seat post as follow-ups.
  • Checks: 54 check-runs on this head, 40 success, 14 skipped, 0 failure, none pending; Lint & Repo Gates and TypeScript Type Check success. mergeable_state clean.
  • Gates (dev, at this head): @objectstack/plugin-auth 228 files / 4105 tests passed; plugin-auth typecheck and the 10 direct consumers' typecheck exit 0; dispatch-gates --commands 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN; full dogfood battery ((narrowing) arm) pinned to this head: 423 files passed, 3 skipped, 0 failures. The ablation legs were read at 4b2c6130c0; auth-manager.ts and auth-plugin.ts are byte-identical at this head and allowed-ip-ranges.ts changed only its copy table and lookup, so they stand (reviewer confirmed).
  • Prose faces read against the diff: the changeset sentences "An entry is an IPv4 or IPv6 address, or an address, / and a prefix length written in decimal digits", "An entry that names no range matches no address, its own base address included", "A value that is not a list of entries written as text is refused with 400 SETTINGS_VALIDATION" and the lock-out step "clear the stored value (PUT /api/settings/auth with allowed_ip_ranges: null, the one write the override lets through)" match parseIpRange, readAllowedIpRanges and the save predicate at this head. authentication.mdx and docs/HARDENING.md (prose, not governed) state the same rule.
  • Shape: draft, base main, no closing keyword (no card); Clause-②: no (narrowing), Metadata-protocol: none, Skills-docs: updated here; @objectstack/plugin-auth patch with the BREAKING banner and the ADR-0087 marker. The merge of main kept both sides of three conflicts and added the two new refusal sentences in the seven languages main added.
  • Governed: check-governed-merges.mjs --branch origin/claude/sec-hardening-r8: 0 of 8 paths governed; 690 changed lines ≤ 3000 ⇒ Tier S.
  • Provenance: rule and value-type rule by the previous security#1 holder's dev (session_01L6bhgVfjk2i8T2D4RmxsKg); merge, copy, changeset step, gates, battery and body by this seat's dev; this session dispatched the reviewer.
  • Landing authority: the maintainer's standing order to this account's seats, verbatim: 「按 pm-dispatch 审过的pr你可以自主落地」. Next: relay pr_ready + automerge_enable once the maintainer confirms in the seat thread (this session's auto mode refused the landing stroke on fix(core)!: an API key authenticates only as an owner who exists #30783).

Generated by Claude Code

This branch has not been deployed

No deployments
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/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants