fix(auth): close the account pre-emption + wrong-account link holes (E1 S5, SEC-10/11)

SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose
email was never verified is untrusted — whoever registered the address (to
pre-empt the real owner) could have chosen it. Both the Google-adoption callback
and admin activation (_activate_user_by_email) now drop that pre-set password and
bump the session epoch when the account isn't email_verified, so a later Google
login / admin approval can't hand the pre-set password a working login. No-op for
a verified account (the legit no-SMTP flow). +2 tests.

SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs
resolved to the session DEFAULT account, so a tab acting as a secondary account
would link Google / the YouTube grant to the wrong account. The frontend now
threads the active account through the navigation URL (withActiveAccount ->
?account=<active>), and _complete_link accepts any wallet account, not only the
default, so the identity lands on the account that started the flow.
This commit is contained in:
2026-08-18 03:02:34 +02:00
parent 8ffa65a9da
commit 18edbecb1e
6 changed files with 68 additions and 6 deletions
+11
View File
@@ -19,6 +19,17 @@ export function activeAccountId(): number | null {
}
}
// Append `?account=<active>` to a top-level navigation URL (SEC-11). XHRs carry the active account in
// the X-Siftlode-Account header, but a full-page navigation (window.location) can't, so the OAuth
// link/upgrade GETs would resolve to the SESSION DEFAULT account instead of the tab's active one — the
// identity/token would then land on the wrong account. This threads the active account through the URL
// so resolved_user_id picks it. No-op when there's no distinct active account pinned yet.
export function withActiveAccount(path: string): string {
const id = activeAccountId();
if (id == null) return path;
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
}
// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that
// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the
// account) would never re-read once the account is finally known. That bites exactly once, on first