diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 550363fd9ef..d3ba064bf17 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -291,6 +291,16 @@ func pendingSessionRequiresBindLogin(payload map[string]any) bool { return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "bind_login_required") } +// pendingSessionRequiresExistingAccountBinding 判断 completion payload 是否处于"绑定到已存在本地账户"的选择态。 +// 该状态由 pendingOAuthChoiceCompletionResponse 写入(existing_account_bindable=true),表示当前 OAuth +// 身份按邮箱匹配到了一个既有账户,必须经由 /oauth//bind-login(密码校验)证明账户所有权后才能绑定。 +// SECURITY: 此态下 exchange 绝不能因为携带 adoption 决定(display name/avatar)就落到 applyPendingOAuthAdoption +// 并把身份绑定到 TargetUserID——否则攻击者用未验证邮箱即可接管同邮箱的既有账户(账户接管)。 +func pendingSessionRequiresExistingAccountBinding(payload map[string]any) bool { + v, ok := payload["existing_account_bindable"].(bool) + return ok && v +} + func pendingOAuthCompletionCanIssueTokenPair(session *dbent.PendingAuthSession, payload map[string]any) bool { if session == nil { return false @@ -1932,6 +1942,19 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) { response.Success(c, payload) return } + // SECURITY (account-takeover): 当 OAuth 身份按邮箱命中既有本地账户时,必须经 /oauth//bind-login + // 凭密码证明所有权后才能绑定。adoption 决定(display name/avatar)不得绕过该闸门:仅记录决定供后续绑定使用, + // 然后返回选择态 payload,绝不在此处消费 session / 调用 applyPendingOAuthAdoption。 + if pendingSessionRequiresExistingAccountBinding(payload) { + if adoptionDecision.hasDecision() { + if _, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, adoptionDecision); err != nil { + response.ErrorFrom(c, err) + return + } + } + response.Success(c, payload) + return + } if !adoptionDecision.hasDecision() { adoptionRequired, _ := payload["adoption_required"].(bool) if adoptionRequired { diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 70fb160a30a..7637f248341 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -990,6 +990,91 @@ func TestExchangePendingOAuthCompletionInvitationRequiredFalseFalsePersistsDecis require.Nil(t, storedSession.ConsumedAt) } +// TestExchangePendingOAuthCompletionChoiceStateRejectsAdoptionDrivenBinding 是 S2A-001 的回归测试。 +// 当 OAuth 身份按邮箱命中既有本地账户、会话处于选择态(existing_account_bindable=true)时, +// 攻击者夹带一个 adoption 字段(adopt_avatar)触发 hasDecision()=true 的 exchange 不得绕过 bind-login +// 密码闸门把攻击者身份绑定到受害者账户——否则可凭未验证邮箱接管同邮箱账户。 +func TestExchangePendingOAuthCompletionChoiceStateRejectsAdoptionDrivenBinding(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandler(t, true) + ctx := context.Background() + + victim, err := client.User.Create(). + SetEmail("victim@example.com"). + SetUsername("victim-user"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("choice-takeover-session-token"). + SetIntent("login"). + SetProviderType("linuxdo"). + SetProviderKey("linuxdo"). + SetProviderSubject("attacker-subject-001"). + SetBrowserSessionKey("choice-takeover-browser-session-key"). + SetTargetUserID(victim.ID). + SetResolvedEmail(victim.Email). + SetUpstreamIdentityClaims(map[string]any{ + "suggested_display_name": "Attacker", + }). + SetLocalFlowState(map[string]any{ + oauthCompletionResponseKey: map[string]any{ + "step": oauthPendingChoiceStep, + "adoption_required": true, + "email_binding_required": true, + "existing_account_bindable": true, + "resolved_email": victim.Email, + }, + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + // 攻击载荷:仅夹带一个 adoption 字段,使 hasDecision()=true 以尝试绕过选择态闸门。 + body := bytes.NewBufferString(`{"adopt_avatar":false}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("choice-takeover-browser-session-key")}) + ginCtx.Request = req + + handler.ExchangePendingOAuthCompletion(ginCtx) + + // 仍应停留在选择态:返回 200 + choice payload,且不签发 token。 + require.Equal(t, http.StatusOK, recorder.Code) + data := decodeJSONResponseData(t, recorder) + require.Equal(t, oauthPendingChoiceStep, data["step"]) + require.Nil(t, data["access_token"]) + require.Nil(t, data["refresh_token"]) + + // 关键断言:攻击者的 OAuth 身份未被绑定到任何账户。 + identityCount, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("linuxdo"), + authidentity.ProviderKeyEQ("linuxdo"), + authidentity.ProviderSubjectEQ("attacker-subject-001"), + ). + Count(ctx) + require.NoError(t, err) + require.Zero(t, identityCount, "attacker OAuth identity must not be bound to the victim account") + + // 受害者账户没有新增任何身份。 + victimIdentityCount, err := client.AuthIdentity.Query(). + Where(authidentity.UserIDEQ(victim.ID)). + Count(ctx) + require.NoError(t, err) + require.Zero(t, victimIdentityCount) + + // 会话未被消费,用户仍可走正常的 bind-login(密码校验)流程。 + storedSession, err := client.PendingAuthSession.Get(ctx, session.ID) + require.NoError(t, err) + require.Nil(t, storedSession.ConsumedAt) +} + func TestCreateOIDCOAuthAccountCreatesUserBindsIdentityAndConsumesSession(t *testing.T) { handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "fresh@example.com", "246810") ctx := context.Background() diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index e4fa876c464..c52bc7be13c 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -436,12 +436,19 @@ func (s *AuthService) IsEmailVerifyEnabled(ctx context.Context) bool { return s.settingService.IsEmailVerifyEnabled(ctx) } +// loginTimingDummyHash 是一个固定的 cost-10 bcrypt 哈希,用于在账户不存在时执行等价的密码比对工作量, +// 消除"存在账户走 bcrypt(数十毫秒)/ 不存在账户立即返回"的时间侧信道,避免用户名枚举(S2A-016)。 +// 此值无需保密,安全性来自常量级的等量计算而非其不可知性。 +const loginTimingDummyHash = "$2a$10$We5vWiVqEx4nqCGymPXeA.f2EWchUGN6wk33H32oa8M/ZYai9cT.O" + // Login 用户登录,返回JWT token func (s *AuthService) Login(ctx context.Context, email, password string) (string, *User, error) { // 查找用户 user, err := s.userRepo.GetByEmail(ctx, email) if err != nil { if errors.Is(err, ErrUserNotFound) { + // 对不存在的账户也执行一次 bcrypt 比对,使两条路径耗时一致(防用户名枚举)。 + _ = s.CheckPassword(password, loginTimingDummyHash) return "", nil, ErrInvalidCredentials } // 记录数据库错误但不暴露给用户 diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index 2279d913209..69fc7c846c3 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -7,6 +7,7 @@ import ( "context" "embed" "encoding/json" + "html" "io" "io/fs" "net/http" @@ -230,7 +231,9 @@ func injectSiteTitle(html, settingsJSON []byte) []byte { return html } - newTitle := []byte("" + cfg.SiteName + " - AI API Gateway") + // SECURITY: SiteName 来自管理员可写设置,未经转义直接拼进 HTML 会造成存储型 HTML 注入(S2A-019)。 + // 用 html.EscapeString 转义,确保它只作为文本进入 。 + newTitle := []byte("<title>" + html.EscapeString(cfg.SiteName) + " - AI API Gateway") var buf bytes.Buffer buf.Write(html[:titleStart]) buf.Write(newTitle)