Skip to content

fix(security): OAuth account-takeover via adoption-bypass, login timing oracle, SiteName HTML injection#2929

Open
jaxxjj wants to merge 1 commit into
Wei-Shaw:mainfrom
jaxxjj:jaxxjj/security-hardening-oauth-takeover
Open

fix(security): OAuth account-takeover via adoption-bypass, login timing oracle, SiteName HTML injection#2929
jaxxjj wants to merge 1 commit into
Wei-Shaw:mainfrom
jaxxjj:jaxxjj/security-hardening-oauth-takeover

Conversation

@jaxxjj
Copy link
Copy Markdown

@jaxxjj jaxxjj commented May 31, 2026

Summary

Fixes three security issues found during a code review of the gateway. Scoped to small, low-risk changes only — no schema migrations or default-behavior changes.

🔴 Critical — OAuth account takeover via adoption-bypass (auth pending flow)

When a third-party login matches an existing local account by email, the flow is supposed to require the user to prove ownership via /oauth/<provider>/bind-login (password) before the OAuth identity is linked. But ExchangePendingOAuthCompletion only showed that choice/bind screen when the request body carried no adoption decision (if !adoptionDecision.hasDecision()). Including any adoption field (e.g. {"adopt_avatar":false}) bypassed the guard and fell through to applyPendingOAuthAdoption(..., session.TargetUserID), binding a brand-new OAuth identity to the email-matched account with no password check. On the next login the attacker received a full token pair for the victim.

With a permissive IdP (or LinuxDo, which has no email_verified) and the default OIDCConnectRequireEmailVerified=false, an attacker who knows a victim's email (e.g. an admin's) could take over the account.

Fix: add an explicit existing_account_bindable guard in the exchange handler. While a session is in the account-choice/binding state, any adoption decision is recorded but the handler never binds or consumes the session — binding still requires the password bind-login path. The existing_account_bindable flag is set only on the bind-to-existing-account path (pendingOAuthChoiceCompletionResponse), never on new-account adoption, so legitimate flows (new-account profile adoption, existing-identity login, bind_current_user, password bind-login) are unaffected. Includes a regression test that fires the exact bypass payload and asserts no identity is bound and the session is not consumed.

🟡 Low — login timing oracle / username enumeration (AuthService.Login)

Login returned immediately on user-not-found but ran a full bcrypt compare (tens of ms) for existing accounts, so response latency revealed whether an email is registered. Run a dummy bcrypt compare against a fixed hash on the not-found path to equalize work. (Forgot-password was already hardened this way; login was not.)

🟡 Low — stored HTML injection via SiteName in <title>

The admin-set SiteName was concatenated into the server-rendered <title> with no escaping. CSP blocks script execution, but HTML/UI injection (content spoofing, phishing overlay) was possible and served to all visitors. Escape with html.EscapeString.

Test plan

  • go build ./internal/handler/... ./internal/service/... — passes
  • New regression test TestExchangePendingOAuthCompletionChoiceStateRejectsAdoptionDrivenBinding covers the critical fix (fails before the patch, passes after)
  • gofmt clean on all changed files

Not included (flagged for maintainers)

These came up in the same review but need design/migration decisions, so they're intentionally out of scope here:

  • Upstream account credentials & payment provider keys stored plaintext at rest (an AES-256-GCM encryptor already exists in-tree but isn't applied to these) — needs encryption + migration
  • SSRF protections (security.url_allowlist.*) and the first-run setup wizard defaulting to 0.0.0.0 — fixing safely means changing defaults/UX
  • API keys stored plaintext (vs hashed) — needs a migration
  • No per-account login lockout — needs new rate-limit state

…ose login timing oracle; escape SiteName in <title>

Addresses three issues found in a security review:

- CRITICAL (S2A-001): OAuth pending-completion exchange bound an attacker's
  OAuth identity to a pre-existing local account matched by (unverified) email
  without a password check, because including any adoption field in the request
  body bypassed the choice-screen guard. Add an explicit
  existing_account_bindable guard in ExchangePendingOAuthCompletion: while a
  session is in the account-choice/binding state, record any adoption decision
  but never fall through to applyPendingOAuthAdoption — binding still requires
  /oauth/<provider>/bind-login with password proof. Adds a regression test.

- LOW (S2A-016): password login ran bcrypt only for existing accounts, leaking
  account existence via response timing. Run a dummy bcrypt compare on the
  user-not-found path to equalize work.

- LOW (S2A-019): admin-set SiteName was concatenated into the server-rendered
  <title> without escaping (stored HTML injection). Escape with
  html.EscapeString.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 31, 2026

Thank you for your contribution! Before we can merge this PR, we need you to sign our Contributor License Agreement (CLA).

To sign, please reply with the following comment:

I have read the CLA Document and I hereby sign the CLA

You only need to sign once — it will be valid for all your future contributions to this project.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@alva-bot01
Copy link
Copy Markdown

I have read the CLA Document and I hereby sign the CLA

@alva-bot01
Copy link
Copy Markdown

recheck

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants