Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 74 additions & 36 deletions internal/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,28 +710,32 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string
return nil, fmt.Errorf("github profile decode: %w", err)
}

if profile.Email == "" {
// Fetch primary email separately
emailReq, _ := http.NewRequestWithContext(ctx, "GET", githubUserEmailURL, nil)
emailReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
emailResp, err := client.Do(emailReq)
if err == nil {
defer func() { _ = emailResp.Body.Close() }()
body, _ := io.ReadAll(emailResp.Body)
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if json.Unmarshal(body, &emails) == nil {
for _, e := range emails {
// Only accept the primary AND verified address —
// an unverified email is attacker-controllable and
// must never seed a platform identity.
if e.Primary && e.Verified {
profile.Email = e.Email
break
}
// SECURITY (bug bash #9): GitHub's /user endpoint returns the account's
// PUBLIC profile email, which can be UNVERIFIED and is attacker-settable —
// trusting it lets an attacker link into a victim's account by email. We
// therefore IGNORE profile.Email entirely and ALWAYS resolve the address
// from /user/emails, accepting ONLY a primary+verified entry. If none
// exists, Email stays "" and findOrCreateUserGitHub refuses to link/create.
profile.Email = ""
emailReq, _ := http.NewRequestWithContext(ctx, "GET", githubUserEmailURL, nil)
emailReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
emailResp, err := client.Do(emailReq)
if err == nil {
defer func() { _ = emailResp.Body.Close() }()
body, _ := io.ReadAll(emailResp.Body)
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if json.Unmarshal(body, &emails) == nil {
for _, e := range emails {
// Only accept the primary AND verified address — an unverified
// email is attacker-controllable and must never seed/link a
// platform identity.
if e.Primary && e.Verified {
profile.Email = e.Email
break
}
}
}
Expand Down Expand Up @@ -761,6 +765,14 @@ func (h *AuthHandler) findOrCreateUserGitHub(ctx context.Context, gh *gitHubUser
return nil, nil, fmt.Errorf("findOrCreateUserGitHub lookup: %w", err)
}

// SECURITY (bug bash #9): an EXISTING github_id match (handled above) may
// proceed regardless, but link-by-email / new-identity creation MUST have a
// verified primary email (fetchGitHubUser only sets gh.Email from a
// primary+verified /user/emails entry). Refuse otherwise.
if gh.Email == "" {
return nil, nil, errOAuthEmailUnverified
}

// No GitHub-ID match. Before creating a brand-new team/user — which
// fragments the identity of someone who already signed up via magic-link
// or Google — try to match an existing account by email and attach the
Expand Down Expand Up @@ -832,8 +844,21 @@ type googleUser struct {
Sub string
Email string
Name string
// EmailVerified is Google's assertion that it controls/verified the
// address. Populated from the ID-token's `email_verified` (a STRING
// "true"/"false" on the tokeninfo endpoint) or the userinfo v2
// `verified_email` (bool). We refuse to link-by-email or seed a new
// identity on an unverified email (bug bash #7).
EmailVerified bool
}

// errOAuthEmailUnverified is returned by findOrCreateUserGitHub /
// findOrCreateUserGoogle when an OAuth provider could not assert a verified
// primary email and the request would otherwise create or link an identity
// by that email. Closes the account-takeover vector (bug bash #7/#9). The
// OAuth callbacks map it to a 4xx login failure.
var errOAuthEmailUnverified = errors.New("oauth provider did not supply a verified email")

func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*googleUser, error) {
verifyURL := fmt.Sprintf("%s?id_token=%s", googleTokenInfoURL, url.QueryEscape(idToken))
req, _ := http.NewRequestWithContext(ctx, "GET", verifyURL, nil)
Expand All @@ -850,11 +875,12 @@ func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*google
}

var payload struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Aud string `json:"aud"`
Error string `json:"error_description"`
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Aud string `json:"aud"`
Error string `json:"error_description"`
EmailVerified string `json:"email_verified"` // tokeninfo returns "true"/"false" as a string
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("google payload decode: %w", err)
Expand All @@ -867,9 +893,10 @@ func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*google
}

return &googleUser{
Sub: payload.Sub,
Email: payload.Email,
Name: payload.Name,
Sub: payload.Sub,
Email: payload.Email,
Name: payload.Name,
EmailVerified: payload.EmailVerified == "true",
}, nil
}

Expand Down Expand Up @@ -931,9 +958,10 @@ func fetchGoogleUserInfoOAuth2V2(ctx context.Context, accessToken string) (*goog
}

var payload struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
VerifiedEmail bool `json:"verified_email"` // userinfo v2 returns a bool
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("google userinfo decode: %w", err)
Expand All @@ -946,9 +974,10 @@ func fetchGoogleUserInfoOAuth2V2(ctx context.Context, accessToken string) (*goog
}

return &googleUser{
Sub: payload.ID,
Email: payload.Email,
Name: payload.Name,
Sub: payload.ID,
Email: payload.Email,
Name: payload.Name,
EmailVerified: payload.VerifiedEmail,
}, nil
}

Expand Down Expand Up @@ -1383,6 +1412,15 @@ func (h *AuthHandler) findOrCreateUserGoogle(ctx context.Context, g *googleUser)
return nil, nil, fmt.Errorf("findOrCreateUserGoogle lookup: %w", err)
}

// SECURITY (bug bash #7): only an EXISTING google_id match (handled above)
// may proceed on an unverified email. For link-by-email or new-identity
// creation we MUST require a Google-verified email — otherwise an attacker
// who controls an unverified Google account whose email equals a victim's
// could link into / impersonate the victim's account.
if !g.EmailVerified {
return nil, nil, errOAuthEmailUnverified
}

// Match existing account by email and link google_id when unset.
if g.Email != "" {
byEmail, errEmail := models.GetUserByEmail(ctx, h.db, strings.ToLower(strings.TrimSpace(g.Email)))
Expand Down
4 changes: 3 additions & 1 deletion internal/handlers/auth_final2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ func startEmptyNameGoogleOAuth(t *testing.T, sub, email string) {
mux := http.NewServeMux()
mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"","aud":"g-client"}`, sub, email)
// email_verified:"true" — Google OAuth now requires a verified email
// (bug bash #7); this bespoke server must emit it like the real one.
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"","aud":"g-client","email_verified":"true"}`, sub, email)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
Expand Down
63 changes: 58 additions & 5 deletions internal/handlers/auth_oauth_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,20 @@ import (
// successive requests can return different identities (existing-user + link).
type fakeOAuthServer struct {
ghID string
ghEmail string // email returned by /gh/user (empty → forces /gh/emails fetch)
ghPrimaryEmail string // email returned by /gh/emails as primary+verified
ghEmail string // email returned by /gh/user (public profile email — no longer trusted)
ghPrimaryEmail string // email returned by /gh/emails as primary+verified (defaults to ghEmail)
ghUnverified bool // when true, /gh/emails returns verified:false (simulate unverified)
ghTokenErr bool
gAud string
gSub string // google subject id (default g-sub-123)
gEmail string // google email (default g@example.com)
gUnverified bool // when true, Google userinfo/tokeninfo report email NOT verified
gTokenNoAccess bool
}

// gVerified renders the Google verified flag (default true) for the harness.
func (f *fakeOAuthServer) gVerified() bool { return !f.gUnverified }

func (f *fakeOAuthServer) gSubOr() string {
if f.gSub != "" {
return f.gSub
Expand Down Expand Up @@ -89,11 +94,24 @@ func (f *fakeOAuthServer) handler() http.Handler {
})
mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `[{"email":%q,"primary":true,"verified":true}]`, f.ghPrimaryEmail)
// fetchGitHubUser now ALWAYS resolves the email from /user/emails
// (the public /user email is no longer trusted — bug bash #9). For a
// realistic verified-primary, fall back to ghEmail when ghPrimaryEmail
// isn't explicitly set. Tests that want to simulate an unverified /
// missing primary set ghUnverified=true.
primary := f.ghPrimaryEmail
if primary == "" {
primary = f.ghEmail
}
verified := "true"
if f.ghUnverified {
verified = "false"
}
_, _ = fmt.Fprintf(w, `[{"email":%q,"primary":true,"verified":%s}]`, primary, verified)
})
mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"G User","aud":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud)
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"G User","aud":%q,"email_verified":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud, map[bool]string{true: "true", false: "false"}[f.gVerified()])
})
mux.HandleFunc("/g/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand All @@ -105,7 +123,7 @@ func (f *fakeOAuthServer) handler() http.Handler {
})
mux.HandleFunc("/g/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":%q,"email":%q,"name":"G User"}`, f.gSubOr(), f.gEmailOr())
_, _ = fmt.Fprintf(w, `{"id":%q,"email":%q,"name":"G User","verified_email":%t}`, f.gSubOr(), f.gEmailOr(), f.gVerified())
})
return mux
}
Expand Down Expand Up @@ -239,6 +257,41 @@ func TestAuth_GitHub_HappyPath(t *testing.T) {
assert.NotEmpty(t, body["token"])
}

// bug bash #9: GitHub OAuth with NO primary+verified email must be REFUSED
// (no link/create on an unverified address). ghUnverified makes /gh/emails
// report verified:false → fetchGitHubUser resolves an empty email →
// findOrCreateUserGitHub returns errOAuthEmailUnverified.
func TestAuth_GitHub_UnverifiedEmail_Refused(t *testing.T) {
db, clean := testhelpers.SetupTestDB(t)
defer clean()
settleAuditDB(t, db)
startFakeOAuth(t, &fakeOAuthServer{ghID: uniqueGHID(), ghEmail: testhelpers.UniqueEmail(t), ghUnverified: true})

app := buildAuthApp(handlers.NewAuthHandler(db, oauthCfg()))
resp := oauthPostJSON(t, app, "/auth/github", `{"code":"abc"}`)
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatalf("GitHub login with no verified email must NOT succeed; got 200")
}
}

// bug bash #7: Google OAuth with email NOT verified must be REFUSED for a new
// identity. gUnverified makes userinfo/tokeninfo report the email unverified →
// findOrCreateUserGoogle returns errOAuthEmailUnverified.
func TestAuth_Google_UnverifiedEmail_Refused(t *testing.T) {
db, clean := testhelpers.SetupTestDB(t)
defer clean()
settleAuditDB(t, db)
startFakeOAuth(t, &fakeOAuthServer{gSub: "g-unverified-" + uniqueGHID(), gEmail: testhelpers.UniqueEmail(t), gUnverified: true})

app := buildAuthApp(handlers.NewAuthHandler(db, oauthCfg()))
resp := oauthPostJSON(t, app, "/auth/google", `{"id_token":"abc"}`)
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatalf("Google login with unverified email must NOT succeed; got 200")
}
}

func TestAuth_GitHub_MissingCodeAndBadBody(t *testing.T) {
app := buildAuthApp(handlers.NewAuthHandler(nil, oauthCfg()))

Expand Down
64 changes: 64 additions & 0 deletions internal/handlers/auth_oauth_helpers_whitebox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,67 @@ func TestAuth_generateOAuthState_And_generateSessionID(t *testing.T) {
require.NoError(t, err)
assert.Len(t, s2, 32)
}

// --- bug bash #9: GitHub email must come from a primary+verified entry ---

func TestAuth_exchangeGitHubCode_IgnoresUnverifiedPublicEmail(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/gh/token", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"access_token":"x"}`))
})
mux.HandleFunc("/gh/user", func(w http.ResponseWriter, r *http.Request) {
// Attacker-controllable PUBLIC profile email — must be ignored.
_, _ = w.Write([]byte(`{"id":7,"login":"octocat","email":"attacker-public@evil.test"}`))
})
mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"email":"real-verified@example.com","primary":true,"verified":true}]`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
setHelperURLs(t, srv.URL)

gh, err := exchangeGitHubCode(context.Background(), "id", "secret", "code")
require.NoError(t, err)
require.Equal(t, "real-verified@example.com", gh.Email,
"must resolve the verified primary, NOT the public /user email")
}

func TestAuth_exchangeGitHubCode_NoVerifiedPrimary_EmptyEmail(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/gh/token", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"access_token":"x"}`))
})
mux.HandleFunc("/gh/user", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"id":8,"login":"octocat","email":"public@evil.test"}`))
})
mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) {
// Primary but NOT verified → must be ignored, leaving Email empty.
_, _ = w.Write([]byte(`[{"email":"public@evil.test","primary":true,"verified":false}]`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
setHelperURLs(t, srv.URL)

gh, err := exchangeGitHubCode(context.Background(), "id", "secret", "code")
require.NoError(t, err)
require.Equal(t, "", gh.Email, "no primary+verified email → Email empty (login then refused)")
}

// --- bug bash #7: Google verified_email flows onto googleUser.EmailVerified ---

func TestAuth_fetchGoogleUserInfo_VerifiedEmailFlag(t *testing.T) {
serve := func(body string) *googleUser {
mux := http.NewServeMux()
mux.HandleFunc("/g/userinfo", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
})
srv := httptest.NewServer(mux)
defer srv.Close()
setHelperURLs(t, srv.URL)
g, err := fetchGoogleUserInfoOAuth2V2(context.Background(), "tok")
require.NoError(t, err)
return g
}
require.True(t, serve(`{"id":"g1","email":"u@example.com","verified_email":true}`).EmailVerified)
require.False(t, serve(`{"id":"g1","email":"u@example.com","verified_email":false}`).EmailVerified)
}
Loading
Loading