Skip to content

fix(plugin-auth): a change-email request notifies the current address whatever way the caller is signed in - #30166

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/sec-hardening-x
Nov 4, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/sec-hardening-x

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:POST /api/v1/auth/change-email 被接受后发往账号当前邮箱的 auth.email_change_notice,收件人改为在全局 after-hook 里从端点自己解析出的会话读取;全局 before-hook 里那一段单独的会话读取删除。
  • 为什么改:[finding] change-email verifies only the NEW address, so a hijacked session can move the account identity without the old mailbox ever being told #8019 的裁决要求每个被接受的改邮箱请求都通知账号的当前邮箱,与调用方以何种方式登录无关;本 PR 让这条规则对每种登录方式都成立。
  • 风险与回滚:风险低。通知的模板与文案不变;改邮箱流程本身(确认信发往新地址、点击前不改动)不变;被拒绝的请求仍然一封信都不发;通知失败仍不阻断流程。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-auth/src/auth-manager-after-hook.ts(/change-email 一段与 changeEmailFrom);auth-manager-before-hook.ts(删除的一段);change-email-delete-user-wiring.test.ts 新增的顶层 describe。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/change-email-delete-user-wiring.test.ts,预期 20 个用例全部通过,其中新增 5 个。
  • 需要你做的:无,席位落地。

What changes

  • The rule (the [finding] change-email verifies only the NEW address, so a hijacked session can move the account identity without the old mailbox ever being told #8019 ruling, unchanged): every accepted POST /api/v1/auth/change-email request mails the account's current address the auth.email_change_notice, whatever way the caller is signed in.
  • auth-manager-after-hook.ts: the /change-email section takes the notice's recipient from the user of the session the endpoint itself acted on, ctx.context.session.user. better-auth's sensitiveSessionMiddleware records that session before the handler runs, and the handler does not write it, so it names the account the request changes, with the address it holds as the request arrives. A small helper, changeEmailFrom, shapes the recipient. The success gate, the failure-swallowing send and the refusal coding that follow are unchanged.
  • auth-manager-before-hook.ts: the /change-email capture is removed, since the after-hook now reads the address itself; the module header's list of moved sections no longer names it.
  • auth-manager-auth-instance.ts: one comment pointer that named the removed key now names auth-manager-after-hook.ts.
  • identity-mail-sibling.test.ts, auth-email-locale.test.ts: the three fixtures that drive the after-hook directly hand it context.session.user in place of the removed key; their assertions are unchanged.
  • Changeset change-email-notice-every-sign-in.md: @objectstack/plugin-auth patch.

Tests

A new top-level describe in change-email-delete-user-wiring.test.ts, the change-email harness, named for the rule: "a change-email request notifies the current address whatever way the caller is signed in".

  • For each way of signing in (the session cookie; a bearer token alone): an accepted request mails exactly one notice, to the current address, naming the requested address and the account id. The acceptance is asserted too (a 200 and one confirmation to the requested address), and the stored address has not moved.
  • For each way of signing in: a refused request (the account's own address, 400 VALIDATION_ERROR) mails nothing at all.
  • A caller signed in as two accounts at once: the notice goes to the current address of the account the request changes (the one its confirmation is for), and to no other.

Acceptance notes

  • Not stacked on any open PR. fix(plugin-auth): a verification-email resend from a visitor who is not signed in answers the same whatever the send does #30144 (open) also edits auth-manager-before-hook.ts and auth-manager-auth-instance.ts; neither PR depends on the other, their hunks are at least 50 lines apart, and git merge-tree of the two heads is clean, with no reference to the removed key left in the combined tree.
  • main was merged in before opening (seven commits, none under packages/plugins/plugin-auth); @objectstack/spec and @objectstack/driver-sql, which the merge touched, were rebuilt before the typecheck and the final test runs. The full plugin-auth suite ran on the pre-merge tree with the fix applied (below); per AGENTS.md §10 it was not re-run after a merge with no overlap.

Verification

Tests and typecheck
plugin-auth full suite @ 5505e3eaf2 with the fix applied
(e312a228c2 is that tree plus the two-account case; os-verify-lock held, closure built first)
  Test Files  220 passed (220)
       Tests  3819 passed | 10 skipped (3829)
  plugin-auth full suite exit=0 · VERDICT command-exit 0

pnpm --filter @objectstack/plugin-auth typecheck @ a014cda92f: exit 0
  check:test-typecheck: OK — 10 file(s) / 88 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json

change-email-delete-user-wiring + identity-mail-sibling + auth-email-locale @ a014cda92f
  Test Files  3 passed (3)
       Tests  95 passed (95)
  targeted tests exit=0 · VERDICT command-exit 0

eslint --no-inline-config on the six changed .ts files @ a014cda92f: exit 0
One-shot reverse verification
@ e503babfb9, one lock-held script with a restoring trap
auth-manager-after-hook.ts and auth-manager-before-hook.ts restored to the base blobs
  (git hash-object == base blob: db93a8ac72, f1f1fec336)
change-email-delete-user-wiring.test.ts: exit 1 — the new describe goes red;
  every pre-existing case in the file stays green
restored with git checkout HEAD (hash-object == HEAD blob: dd1e908249, 5cb1f28246;
  git diff HEAD empty; status clean; HEAD unmoved)
change-email-delete-user-wiring.test.ts: exit 0, Tests 20 passed (20)

The tests import src/ relatively, so there is no dist/ leg. The later commit
a014cda92f changes only how one acceptance assertion reads the recipient
(through the EmailAddress union); it was re-run green at a014cda92f above.
Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  (no paths: change set from the merge base) -> 60 commands

@ a014cda92f: 59 of them re-run, each exit captured before any pipe: 59 x exit 0
  (check:dual-build-cjs-loads included, measured over a full build:
   "116 published require entry point(s) across 71 package(s) load")
@ e503babfb9: pnpm check:type-check-debt exit 0
  "0 ledger entr(ies) re-measured ... none above its recorded number"
  (not re-run at a014cda92f: that commit touches three lines of one test file,
   which plugin-auth's own typecheck reads green at a014cda92f)

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE
  Run reconciliation — 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN.
  all 60 accounted families carry an exit code

Repo-wide scans (pnpm lint and the rest of Lint & Repo Gates) are CI's.

Generated by Claude Code

… whatever way the caller is signed in

The notice's recipient is read from the session the endpoint itself acted
on, in the global after-hook; the separate session read in the global
before-hook is removed. Pins cover each way of signing in, and a refused
request still mails nothing.

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

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

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

Which tree this was computed on

This run read content/docs from f833d29249bd0240000db929d5b55ec166e1d49d — the merge of head a014cda92fafb4789abc61f1d26e40bfe3c499a3 into base fc869c24e0a0041d24485802286fce3f9caedc13, 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 f833d29249bd0240000db929d5b55ec166e1d49d && git checkout f833d29249bd0240000db929d5b55ec166e1d49d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fc869c24e0a0041d24485802286fce3f9caedc13 a014cda92fafb4789abc61f1d26e40bfe3c499a3 && git checkout -B drift-repro fc869c24e0a0041d24485802286fce3f9caedc13 && git merge --no-ff a014cda92fafb4789abc61f1d26e40bfe3c499a3

node scripts/docs-audit/affected-docs.mjs --json fc869c24e0a0041d24485802286fce3f9caedc13

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head a014cda92fafb4789abc61f1d26e40bfe3c499a3 · 2026-11-04T18:48Z

  • Shape: 7 files, +141/−39, not governed, under 3000. Patch for plugin-auth. Clause-②: no, Metadata-protocol: none.
  • Review: the seat's own read at TIER_DEFAULT, because the diff touches no contract face and so needs no second agent.
  • Rule held ([finding] change-email verifies only the NEW address, so a hijacked session can move the account identity without the old mailbox ever being told #8019): every accepted change-email request notifies the account's current address, whichever way the caller is signed in.
    • The recipient now comes from the session the endpoint itself acted on (ctx.context.session.user, read in the after-hook). That session is the account being changed, holding its address as the request arrived.
    • The before-hook stash is deleted, so the recipient is resolved in exactly one place.
    • The template, the confirmation mail and the refused-request path are unchanged.
  • Changeset prose, checked line by line against the diff:
    • "whatever way the caller is signed in": the endpoint's session, whichever credential carried it.
    • "goes to the account the request changes": pinned by the two-account case.
    • "Unchanged …": the success gate and the swallowing send are untouched.
    • All of it stays at rule level (§6).
  • Dev's open question 3 is answered A. The two-account case notifying the account being changed is the rule's own subject, not new behaviour.
  • Check-runs at this head (latest per name): 13 skipped, 35 success. mergeable_state: clean.
  • Local gates (dev):
    • dispatch-gates --ran: 60 derived, 60 run, 0 NOT MEASURED.
    • Full plugin-auth suite: 3819 passed.
    • Reverse leg: 2 red at base, green at head.
  • Landing: ready, then auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 4, 2026 18:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 4, 2026 18:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 85c7374 Nov 4, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/sec-hardening-x branch November 4, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant