Smail/test/review/external_email_http_findings_test.go

171 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package http_server
import (
"encoding/json"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/controllers"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/verification"
"git.pcux.cn/xuans/Smail/session"
"git.pcux.cn/xuans/Smail/utils/csrf"
"github.com/alexedwards/scs/v2"
"xorm.io/xorm"
)
type auditExternalHTTPFixture struct {
engine *xorm.Engine
user models.User
repository *externalemail.Repository
server *httptest.Server
client *http.Client
csrfToken string
issued verification.Challenge
deliveredCode string
}
// 仅测试专用入口签发真实会话;被审查的路由、会话中间件、CSRF 和数据库均不替换。
func newAuditExternalHTTPFixture(t *testing.T, age time.Duration) *auditExternalHTTPFixture {
t.Helper()
oldConfig, oldInit, oldDevelopment := config.Instance, config.IsInit, config.DevelopmentMode
oldDB, oldSession, oldManager := db.Instance, session.Instance, config.CurrentManager()
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external-http.db"))
if err != nil { t.Fatal(err) }
t.Cleanup(func() {
controllers.ConfigureSecurityRuntime(nil, nil, nil, nil)
config.Instance, config.DevelopmentMode = oldConfig, oldDevelopment
config.SetInitState(oldInit)
config.SetRuntimeManager(oldManager)
db.Instance, session.Instance = oldDB, oldSession
_ = engine.Close()
})
for _, model := range []any{&models.User{}, &models.UserEmail{}, &models.Email{}, &models.ExternalEmail{}, &models.RecoveryCode{}} {
if err := engine.Sync2(model); err != nil { t.Fatal(err) }
}
fixture := &auditExternalHTTPFixture{engine: engine, user: models.User{
Account: "audit-user", Name: "Audit", Role: "user", SessionVersion: 1, SecurityVersion: 1,
}}
if _, err := engine.Insert(&fixture.user); err != nil { t.Fatal(err) }
config.SetRuntimeManager(nil)
config.Instance = &config.Config{Domain: "mail.example.test", WebDomain: "127.0.0.1"}
config.DevelopmentMode = false
config.SetInitState(true)
db.Instance = engine
session.Instance = scs.New()
session.Instance.Cookie.Secure = true
vault := externalemail.NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
fixture.repository, err = externalemail.NewRepository(engine, vault)
if err != nil { t.Fatal(err) }
store, err := verification.NewStore(32)
if err != nil { t.Fatal(err) }
service, err := verification.NewService(store, func(challenge verification.Challenge, code string) error {
fixture.issued, fixture.deliveredCode = challenge, code
return nil
}, nil)
if err != nil { t.Fatal(err) }
controllers.ConfigureSecurityRuntime(service, fixture.repository, nil, nil)
mux := http.NewServeMux()
if err := router(mux); err != nil {
t.Fatal(err)
}
mux.HandleFunc("/audit/session", func(w http.ResponseWriter, r *http.Request) {
if err := session.PutUser(r.Context(), &fixture.user); err != nil { http.Error(w, "session", 500); return }
identity, err := session.GetUser(r.Context())
if err != nil { http.Error(w, "identity", 500); return }
identity.AuthenticatedAt = time.Now().Add(-age).Unix()
encoded, err := json.Marshal(identity)
if err != nil { http.Error(w, "encoding", 500); return }
session.Instance.Put(r.Context(), "user", string(encoded))
token, err := csrf.Issue(w, r, true)
if err != nil { http.Error(w, "csrf", 500); return }
_ = json.NewEncoder(w).Encode(map[string]any{"token": token, "recent": identity.HasRecentAuthentication(time.Now())})
})
fixture.server = httptest.NewTLSServer(session.Instance.LoadAndSave(mux))
t.Cleanup(fixture.server.Close)
fixture.client = fixture.server.Client()
fixture.client.Jar, err = cookiejar.New(nil)
if err != nil { t.Fatal(err) }
seed, err := fixture.client.Get(fixture.server.URL + "/audit/session")
if err != nil { t.Fatal(err) }
defer seed.Body.Close()
var seeded struct { Token string `json:"token"`; Recent bool `json:"recent"` }
if err := json.NewDecoder(seed.Body).Decode(&seeded); err != nil { t.Fatal(err) }
if seeded.Recent != (age < 10*time.Minute) || seeded.Token == "" { t.Fatal("会话近期认证前置条件不符") }
fixture.csrfToken = seeded.Token
return fixture
}
func (fixture *auditExternalHTTPFixture) post(t *testing.T, path string, input any, withCSRF bool) response.Response {
t.Helper()
body, err := json.Marshal(input)
if err != nil { t.Fatal(err) }
request, err := http.NewRequest(http.MethodPost, fixture.server.URL+path, strings.NewReader(string(body)))
if err != nil { t.Fatal(err) }
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Origin", fixture.server.URL)
if withCSRF { request.Header.Set(csrf.HeaderName, fixture.csrfToken) }
result, err := fixture.client.Do(request)
if err != nil { t.Fatal(err) }
defer result.Body.Close()
var decoded response.Response
if err := json.NewDecoder(result.Body).Decode(&decoded); err != nil { t.Fatal(err) }
return decoded
}
// 近期会话为正向对照,过期近期认证的会话仍登录有效,但不应能新增恢复凭据。
func TestAuditExternalBindingMustRequireRecentAuthentication(t *testing.T) {
for _, age := range []time.Duration{time.Minute, 11*time.Minute} {
t.Run(age.String(), func(t *testing.T) {
fixture := newAuditExternalHTTPFixture(t, age)
input := map[string]any{"email": "external@example.com", "type": "primary"}
blocked := fixture.post(t, "/api/v1/security/external-email/bind", input, false)
if blocked.ErrorNo == 0 || fixture.issued.ID != "" { t.Fatal("缺少 CSRF 的对照请求未被拒绝") }
bound := fixture.post(t, "/api/v1/security/external-email/bind", input, true)
if bound.ErrorNo != 0 {
if age < 10*time.Minute { t.Fatalf("近期会话绑定对照失败: %d", bound.ErrorNo) }
return
}
if fixture.issued.ID == "" || fixture.deliveredCode == "" { t.Fatal("成功绑定未签发挑战") }
verified := fixture.post(t, "/api/v1/security/external-email/verify", map[string]any{
"id": fixture.issued.ID, "binding_id": fixture.issued.ResourceID,
"email": input["email"], "code": fixture.deliveredCode,
}, true)
stored, err := fixture.repository.FindVerifiedPrimaryByUser(fixture.user.ID)
t.Logf("session_age=%s missing_csrf_error=%d bind_error=%d verify_error=%d verified_binding=%v", age, blocked.ErrorNo, bound.ErrorNo, verified.ErrorNo, err == nil && stored != nil)
if verified.ErrorNo != 0 || err != nil { t.Fatal("绑定成功后的验证前置条件未成立") }
if age >= 10*time.Minute { t.Fatal("已失去近期认证的会话仍能添加并验证外部安全邮箱") }
})
}
}
// 真实主邮箱绑定可被认证解析,不能再被当作独立的外部邮箱绑定和验证。
func TestAuditExternalBindingMustRejectOwnPrimaryMailbox(t *testing.T) {
fixture := newAuditExternalHTTPFixture(t, time.Minute)
address, now := fixture.user.Account+"@mail.example.test", time.Now().UTC()
primary := &models.UserEmail{UserID: fixture.user.ID, Address: address, AddressCanonical: &address, IsPrimary: 1, VerifiedAt: &now}
if _, err := fixture.engine.Insert(primary); err != nil { t.Fatal(err) }
identity, err := auth.ResolveIdentifier(address)
if err != nil || identity.UserID != fixture.user.ID { t.Fatal("主邮箱真实身份解析前置条件未成立") }
bound := fixture.post(t, "/api/v1/security/external-email/bind", map[string]any{"email": address, "type": "primary"}, true)
if bound.ErrorNo != 0 { return }
verified := fixture.post(t, "/api/v1/security/external-email/verify", map[string]any{
"id": fixture.issued.ID, "binding_id": fixture.issued.ResourceID,
"email": address, "code": fixture.deliveredCode,
}, true)
stored, err := fixture.repository.FindVerifiedPrimaryByUser(fixture.user.ID)
t.Logf("primary_identity_resolved=true sent_to_own_mailbox=%v bind_error=%d verify_error=%d verified_primary_external=%v", fixture.issued.Email == address, bound.ErrorNo, verified.ErrorNo, err == nil && stored != nil)
if err != nil || verified.ErrorNo != 0 { t.Fatal("自身邮箱绑定成功但验证前置条件未成立") }
t.Fatal("已确认的 Smail 主邮箱仍可绑定为主要外部邮箱,验证码发往同一邮箱")
}