Skip to content
Open
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
23 changes: 23 additions & 0 deletions backend/internal/handler/auth_oauth_pending_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<provider>/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
Expand Down Expand Up @@ -1932,6 +1942,19 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
response.Success(c, payload)
return
}
// SECURITY (account-takeover): 当 OAuth 身份按邮箱命中既有本地账户时,必须经 /oauth/<provider>/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 {
Expand Down
85 changes: 85 additions & 0 deletions backend/internal/handler/auth_oauth_pending_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/service/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
// 记录数据库错误但不暴露给用户
Expand Down
5 changes: 4 additions & 1 deletion backend/internal/web/embed_on.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"embed"
"encoding/json"
"html"
"io"
"io/fs"
"net/http"
Expand Down Expand Up @@ -230,7 +231,9 @@ func injectSiteTitle(html, settingsJSON []byte) []byte {
return html
}

newTitle := []byte("<title>" + cfg.SiteName + " - AI API Gateway</title>")
// SECURITY: SiteName 来自管理员可写设置,未经转义直接拼进 HTML 会造成存储型 HTML 注入(S2A-019)。
// 用 html.EscapeString 转义,确保它只作为文本进入 <title>。
newTitle := []byte("<title>" + html.EscapeString(cfg.SiteName) + " - AI API Gateway</title>")
var buf bytes.Buffer
buf.Write(html[:titleStart])
buf.Write(newTitle)
Expand Down
Loading