- OAuth2: state 改为 crypto/rand 随机值并写入独立短期 cookie (主会话为 SameSite=Strict,跨站回调不携带,不能放主会话); 回调用 ConstantTimeCompare 校验 state,缺失/不匹配返回 403, 校验后立即清除保证一次性使用。原硬编码 mailgo_oauth2_state 可被利用做授权码注入/登录 CSRF。 - 代理信任: engine.SetTrustedProxies 仅信任 127.0.0.1/::1。 外部直连时 X-Forwarded-For 完全不可信,防止伪造客户端 IP 绕过登录封禁或恶意封禁他人;本机 Caddy/Nginx 转发不受影响。 - CRLF 注入: Web 写信的 To/Cc/Subject 及附件文件名不再原样拼入 MIME 头。新增 sanitizeHeaderField(strip CR/LF/NUL)、 subject 按 RFC 2047 编码、附件名用 mime.FormatMediaType (RFC 2231);附件下载的 Content-Disposition 同步修复。 消息构建抽为 buildOutgoingMessage 纯函数便于测试。 - test: 新增 13 个回归测试(trustedproxy / mail_injection / oauth2_state),覆盖伪造 XFF、注入载荷、state 校验全部分支。
209 lines
6.6 KiB
Go
209 lines
6.6 KiB
Go
package handlers
|
|
|
|
// P1 #2 回归测试:OAuth2 state 必须随机、回调必须校验。
|
|
// 旧实现 state 为硬编码常量且回调完全不校验(登录 CSRF / 授权码注入)。
|
|
|
|
import (
|
|
"html/template"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"mail_go/config"
|
|
"mail_go/internal/mailutil"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// testTemplateFuncs 提供模板解析所需的自定义函数(与 web 包的
|
|
// templateFuncs 等价,但 handlers 包无法反向依赖 web 包)。
|
|
func testTemplateFuncs() template.FuncMap {
|
|
return template.FuncMap{
|
|
"add": func(a, b int) int { return a + b },
|
|
"sub": func(a, b int) int { return a - b },
|
|
"mul": func(a, b int) int { return a * b },
|
|
"div": func(a, b int) int { return a / b },
|
|
"mod": func(a, b int) int { return a % b },
|
|
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
|
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r },
|
|
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
|
|
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
|
"safeJS": func(s string) template.JS { return template.JS(s) },
|
|
"formatBytes": func(b int64) string {
|
|
return "1 KB"
|
|
},
|
|
"decodeHeader": mailutil.DecodeRFC2047,
|
|
"mailName": func(s string) string { return s },
|
|
"mailEmail": func(s string) string { return s },
|
|
"initial": func(s string) string { return "?" },
|
|
"truncate": func(s string, n int) string { return s },
|
|
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") },
|
|
"avatarStyle": func(s string) string { return "background:#eee;color:#333" },
|
|
}
|
|
}
|
|
|
|
func newOAuth2TestContext(t *testing.T) (*gin.Context, *AuthHandler, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
w := httptest.NewRecorder()
|
|
c, engine := gin.CreateTestContext(w)
|
|
// 回调的错误分支渲染 login 模板,需要加载模板及自定义函数
|
|
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
|
|
engine.SetHTMLTemplate(tmpl)
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2", nil)
|
|
|
|
authCfg := config.AuthConfig{
|
|
OAuth2Enabled: true,
|
|
// 使用本地拒绝连接的地址作为 provider,token 交换快速失败,
|
|
// 测试不依赖外部网络。
|
|
OAuth2Provider: "127.0.0.1:1",
|
|
OAuth2ClientID: "test-client-id",
|
|
OAuth2ClientSecret: "test-client-secret",
|
|
OAuth2RedirectURL: "https://mail.example.com/auth/oauth2/callback",
|
|
}
|
|
h := NewAuthHandler(nil, authCfg, config.BanConfig{MaxFailAttempts: 100})
|
|
return c, h, w
|
|
}
|
|
|
|
func TestRandomOAuth2State(t *testing.T) {
|
|
s1, err := randomOAuth2State()
|
|
if err != nil {
|
|
t.Fatalf("randomOAuth2State() error: %v", err)
|
|
}
|
|
if len(s1) != oauth2StateRandLen*2 {
|
|
t.Fatalf("state length = %d, want %d (hex)", len(s1), oauth2StateRandLen*2)
|
|
}
|
|
s2, _ := randomOAuth2State()
|
|
if s1 == s2 {
|
|
t.Fatal("state must be unique per request")
|
|
}
|
|
if s1 == "mailgo_oauth2_state" {
|
|
t.Fatal("state must not be the old hardcoded constant")
|
|
}
|
|
}
|
|
|
|
func TestOAuth2StartSetsRandomStateCookie(t *testing.T) {
|
|
c, h, w := newOAuth2TestContext(t)
|
|
h.OAuth2Start(c)
|
|
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302", w.Code)
|
|
}
|
|
loc := w.Header().Get("Location")
|
|
if !strings.Contains(loc, "state=") {
|
|
t.Fatalf("redirect URL should carry state: %s", loc)
|
|
}
|
|
|
|
// state cookie 必须存在且与 URL 中的一致
|
|
cookies := w.Result().Cookies()
|
|
var stateVal string
|
|
found := false
|
|
for _, ck := range cookies {
|
|
if ck.Name == oauth2StateCookie {
|
|
found = true
|
|
stateVal = ck.Value
|
|
if !ck.HttpOnly {
|
|
t.Error("state cookie must be HttpOnly")
|
|
}
|
|
if !ck.Secure {
|
|
t.Error("state cookie must be Secure")
|
|
}
|
|
if ck.MaxAge <= 0 || ck.MaxAge > oauth2StateMaxAge {
|
|
t.Errorf("state cookie MaxAge = %d, want in (0, %d]", ck.MaxAge, oauth2StateMaxAge)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("OAuth2Start should set state cookie")
|
|
}
|
|
|
|
u, err := url.Parse(loc)
|
|
if err != nil {
|
|
t.Fatalf("parse location: %v", err)
|
|
}
|
|
if u.Query().Get("state") != stateVal {
|
|
t.Fatalf("cookie state %q != URL state %q", stateVal, u.Query().Get("state"))
|
|
}
|
|
|
|
// 两次发起的 state 不同
|
|
c2, h2, w2 := newOAuth2TestContext(t)
|
|
h2.OAuth2Start(c2)
|
|
u2, _ := url.Parse(w2.Header().Get("Location"))
|
|
if u2.Query().Get("state") == stateVal {
|
|
t.Fatal("state must differ between sessions")
|
|
}
|
|
}
|
|
|
|
func TestOAuth2CallbackRejectsMissingOrMismatchedState(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cookieState string
|
|
queryState string
|
|
}{
|
|
{"no cookie", "", "abc"},
|
|
{"no query state", "abc", ""},
|
|
{"mismatch", "abc", "xyz"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c, h, w := newOAuth2TestContext(t)
|
|
q := url.Values{}
|
|
q.Set("code", "test-code")
|
|
if tc.queryState != "" {
|
|
q.Set("state", tc.queryState)
|
|
}
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2/callback?"+q.Encode(), nil)
|
|
if tc.cookieState != "" {
|
|
c.Request.AddCookie(&http.Cookie{Name: oauth2StateCookie, Value: tc.cookieState})
|
|
}
|
|
h.OAuth2Callback(c)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOAuth2CallbackAcceptsValidState(t *testing.T) {
|
|
// 模拟完整流程:Start 下发 state -> Callback 带回同一 state。
|
|
// state 校验通过后应进入后续流程(本测试无真实 IdP,
|
|
// code 交换会失败并渲染登录错误页,但这证明 state 关卡已通过)。
|
|
c, h, w := newOAuth2TestContext(t)
|
|
h.OAuth2Start(c)
|
|
var stateVal string
|
|
for _, ck := range w.Result().Cookies() {
|
|
if ck.Name == oauth2StateCookie {
|
|
stateVal = ck.Value
|
|
}
|
|
}
|
|
|
|
w2 := httptest.NewRecorder()
|
|
c2, engine2 := gin.CreateTestContext(w2)
|
|
tmpl2 := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
|
|
engine2.SetHTMLTemplate(tmpl2)
|
|
q := url.Values{}
|
|
q.Set("code", "test-code")
|
|
q.Set("state", stateVal)
|
|
c2.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2/callback?"+q.Encode(), nil)
|
|
c2.Request.AddCookie(&http.Cookie{Name: oauth2StateCookie, Value: stateVal})
|
|
|
|
h.OAuth2Callback(c2)
|
|
|
|
// state 校验失败返回 403;此处应为非 403(进入 token 交换失败分支)
|
|
if w2.Code == http.StatusForbidden {
|
|
t.Fatalf("valid state was rejected")
|
|
}
|
|
if !strings.Contains(w2.Body.String(), "OAuth2") {
|
|
body := w2.Body.String()
|
|
if len(body) > 200 {
|
|
body = body[:200]
|
|
}
|
|
t.Fatalf("expected OAuth2 error page after state check passed, body: %s", body)
|
|
}
|
|
}
|