fix(security): 修复 P3 低危项(开放重定向/配额TOCTOU/safeJS/会话治理)
- Referer 开放重定向:safeRedirectPath 仅放行同站相对路径, 外部 URL/协议跳转一律回退 /inbox - 发信配额 TOCTOU:新增 TryReserveQuota 原子预扣 (UPDATE ... WHERE used_bytes + n <= quota_bytes),超配额即拒发; 附件保存失败按大小补偿回退 - 移除危险模板函数 safeHTML/safeJS:新增 jsonify(json.Marshal, < > & 转义为 \u003c 等,无法逃出 </script>),compose 页 quill.innerHTML 改用 jsonify;srcdoc 改回默认属性转义 - 会话治理:登录成功后 session.Clear() 清旧状态;记录 loginAt, 绝对过期 7 天 + 滑动续期(活跃会话 12h 写回刷新) - 确认 #15 Content-Disposition 编码随 P1 #4 已完成 - 新增 12 个测试:重定向路径矩阵、配额原子性(含超额不部分扣费)、 jsonify 逃逸防护、会话绝对过期/有效访问(签名会话构造) 至此 16 项安全审计项(P0-P3)全部修复完成。
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
@@ -194,3 +195,75 @@ func TestNewWebServerRejectsBadSecretKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// encodeSessionCookie 用配置密钥伪造一个签名合法的会话 cookie。
|
||||
// 仅用于测试会话治理逻辑(生产密钥不会泄露)。
|
||||
func encodeSessionCookie(t *testing.T, secretKey string, values map[interface{}]interface{}) string {
|
||||
t.Helper()
|
||||
sc := securecookie.New([]byte(secretKey), nil)
|
||||
enc, err := sc.Encode("mail_go_session", values)
|
||||
if err != nil {
|
||||
t.Fatalf("encode session: %v", err)
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
// authCookieValues 构造 AuthMiddleware 可识别的最小会话内容。
|
||||
func authCookieValues(userID uint, loginAt int64) map[interface{}]interface{} {
|
||||
return map[interface{}]interface{}{
|
||||
"userID": userID,
|
||||
"userEmail": "alice@example.com",
|
||||
"isAdmin": false,
|
||||
"loginAt": loginAt,
|
||||
}
|
||||
}
|
||||
|
||||
// P3 #16:会话绝对过期(7 天)后强制重新登录。
|
||||
func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) {
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
ws, _ := newTestWebServer(t, key)
|
||||
srv := httptest.NewServer(ws.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
expired := time.Now().Add(-8 * 24 * time.Hour).Unix()
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, expired))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), "/login") {
|
||||
t.Fatalf("expired session should redirect to /login, got %d Location=%q",
|
||||
resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// P3 #16:未过期会话(含滑动续期窗口内)正常访问。
|
||||
func TestSessionWithinExpiryWorks(t *testing.T) {
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
ws, _ := newTestWebServer(t, key)
|
||||
srv := httptest.NewServer(ws.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix()))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fresh session should access inbox, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user