Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit889e0c5

fix(security): block GitHub OAuth account takeover via unverified email

fix(security): block GitHub OAuth account takeover via unverified email

The GitHub sign-in flow matched an incoming profile's email against an
existing account without checking GitHub had verified that email. GitHub's
GET /user returns an attacker-controllable, possibly-unverified email; the
callback used it directly and only fell back to the primary+verified
address (/user/emails) when /user's email was null. So an attacker could
set an unverified email matching a victim and link into their account.
(The Google flow already checks emailVerified; GitHub didn't.)

Two layers:
- github-oauth.tsx callback: always resolve the primary+verified email via
  fetchGithubPrimaryEmail and use ONLY that for the profile; never trust
  /user's raw email for matching. Set emailVerified accordingly.
- sso.ts findOrCreateUserFromGithub: gate the email→existing-account match
  on `profile.email && profile.emailVerified` (fail closed if absent).
  Added emailVerified to GithubProfile.

Prerequisite before enabling GitHub login in prod. Adds a source-inspection
regression test (the fn needs a live DB to run). github-oauth: 23 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ccanty labs committed on July 8, 2026Parent: 370407e
3 files changed+317889e0c59ff18dfcbb708f0d1b5f8857f5c09da1e
3 changed files+31−7
Modifiedsrc/__tests__/github-oauth.test.ts+13−0View fileUnifiedSplit
320320 expect(src).toContain("github:");
321321 expect(src).toMatch(/github:\$\{[^}]+\.id\}|github:`/);
322322 });
323
324 it("gates email-based account matching on emailVerified (no takeover via unverified email)", () => {
325 // Security regression guard: the email → existing-account match must be
326 // conditioned on profile.emailVerified. Without it, an attacker who sets
327 // an unverified GitHub email matching a victim links into the victim's
328 // account. We assert the guard lives in the function source (same
329 // source-inspection style as the subject-prefix contract above, since the
330 // function needs a live DB to run).
331 const src = findOrCreateUserFromGithub.toString();
332 expect(src).toContain("emailVerified");
333 // The verified flag must be checked alongside the email in the match branch.
334 expect(src).toMatch(/profile\.email\s*&&\s*profile\.emailVerified/);
335 });
323336});
324337
325338// ----------------------------------------------------------------------------
Modifiedsrc/lib/sso.ts+11−2View fileUnifiedSplit
537537 login: string;
538538 name: string | null;
539539 email: string | null;
540 /**
541 * Whether GitHub confirmed this email is verified. MUST be true before we
542 * match `email` against an existing account — otherwise anyone who sets an
543 * unverified address matching a victim's email can take over their account.
544 * Optional/absent is treated as NOT verified (fail closed). Mirrors the
545 * Google flow's `emailVerified` gate.
546 */
547 emailVerified?: boolean;
540548 avatarUrl: string | null;
541549}
542550
590598 };
591599 }
592600
593 // 3. Match by email when present
594 if (profile.email) {
601 // 3. Match by email — ONLY when GitHub verified it. An unverified email
602 // must never link into an existing account (account-takeover vector).
603 if (profile.email && profile.emailVerified) {
595604 const [existing] = await db
596605 .select()
597606 .from(users)
Modifiedsrc/routes/github-oauth.tsx+7−5View fileUnifiedSplit
319319 );
320320 const userinfo = await fetchGithubUserinfo(accessToken);
321321
322 // GitHub may hide email when the user marks all addresses private.
323 let email = userinfo.email;
324 if (!email) {
325 email = await fetchGithubPrimaryEmail(accessToken);
326 }
322 // Account matching MUST use a verified email only — the `/user` email can
323 // be unverified (and is attacker-controlled), so we never trust it for
324 // linking. `fetchGithubPrimaryEmail` returns the primary+verified address
325 // or null. We trust `userinfo.email` only when it equals that verified one.
326 const verifiedEmail = await fetchGithubPrimaryEmail(accessToken);
327 const email = verifiedEmail;
327328
328329 const profile: GithubProfile = {
329330 id: userinfo.id,
330331 login: userinfo.login,
331332 name: userinfo.name,
332333 email,
334 emailVerified: email != null,
333335 avatarUrl: userinfo.avatarUrl,
334336 };
335337
336338