Files
mailgo/internal/web/handlers/mail_injection_test.go
T
kevin 8ea4a623a9 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)全部修复完成。
2026-08-19 16:56:23 +08:00

130 lines
4.2 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 handlers
// P1 #4 回归测试:Web 写信的邮件头不可被 CRLF 注入。
// 旧实现把 to/cc/subject/附件名原样拼进 MIME 头,攻击者可通过
// subject 注入 Reply-To/Bcc 等任意头用于钓鱼。
import (
"strings"
"testing"
)
func TestSanitizeHeaderField(t *testing.T) {
cases := []struct {
in, want string
}{
{"normal value", "normal value"},
{"with\r\ninjected: header", "withinjected: header"},
{"lf\nonly", "lfonly"},
{"cr\ronly", "cronly"},
{"nul\x00byte", "nulbyte"},
{"mixed\r\n\x00all", "mixedall"},
{"中文主题", "中文主题"},
}
for _, tc := range cases {
if got := sanitizeHeaderField(tc.in); got != tc.want {
t.Errorf("sanitizeHeaderField(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestBuildOutgoingMessageBlocksHeaderInjection(t *testing.T) {
_, raw := buildOutgoingMessage(
"alice@example.com",
"bob@example.com\r\nBcc: victim@evil.com",
"carol@example.com\r\nReply-To: attacker@evil.com",
"Hi\r\nBcc: victim@evil.com\r\nReply-To: attacker@evil.com",
"body",
"",
nil,
)
// 注入的头不允许以独立头形式出现
for _, injected := range []string{
"Bcc:", "Reply-To:",
} {
if strings.Contains(raw, "\r\n"+injected) || strings.HasPrefix(raw, injected) {
t.Fatalf("injected header %q found in message:\n%s", injected, raw)
}
}
// 注入的邮箱地址本身允许以折叠形式残留在原头值中,
// 但绝不能成为独立的一行头。
lines := strings.Split(raw, "\r\n")
for _, line := range lines[1:] { // 跳过 From
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Bcc:") || strings.HasPrefix(trimmed, "Reply-To:") {
t.Fatalf("injected header line %q found in message:\n%s", line, raw)
}
}
}
func TestBuildOutgoingMessageAttFilenameInjection(t *testing.T) {
atts := []pendingAttachment{
{filename: "evil.png\r\nBcc: victim@evil.com", contentType: "image/png", data: []byte("x")},
{filename: `quote".png`, contentType: "image/png", data: []byte("x")},
}
_, raw := buildOutgoingMessage("a@b.com", "c@d.com", "", "t", "body", "", atts)
lines := strings.Split(raw, "\r\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Bcc:") {
t.Fatalf("filename header injection found: %q\nmessage:\n%s", line, raw)
}
}
// 含引号/换行的文件名必须被正确编码,不能破坏头结构
if !strings.Contains(raw, "Content-Disposition: attachment;") {
t.Fatalf("Content-Disposition missing in message:\n%s", raw)
}
}
func TestBuildOutgoingMessageEncodesNonASCIISubject(t *testing.T) {
_, raw := buildOutgoingMessage("a@b.com", "c@d.com", "", "中文主题测试", "body", "", nil)
// 非 ASCII 主题应按 RFC 2047 编码为 =?utf-8?...?= 形式
if !strings.Contains(raw, "Subject: =?utf-8?") && !strings.Contains(raw, "Subject: =?UTF-8?") {
t.Fatalf("non-ASCII subject should be RFC 2047 encoded, got:\n%s", raw)
}
// 头部不应再包含裸中文(应被编码)
for _, line := range strings.Split(raw, "\r\n") {
if strings.HasPrefix(line, "Subject:") && strings.ContainsAny(line, "中文测试") {
t.Fatalf("raw non-ASCII in Subject header: %q", line)
}
}
}
func TestFormatContentDisposition(t *testing.T) {
if got := formatContentDisposition("report.pdf"); got != "attachment; filename=report.pdf" {
t.Fatalf("simple filename: got %q", got)
}
// 特殊字符需要安全编码而不是原样嵌入
got := formatContentDisposition("a\"b\\c\r\nd.png")
if strings.ContainsAny(got, "\r\n") {
t.Fatalf("CRLF leaked into Content-Disposition: %q", got)
}
}
// P3 #12Referer 开放重定向防护。
func TestSafeRedirectPath(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", ""},
{"/inbox", "/inbox"},
{"/mail/delete/5", "/mail/delete/5"},
{"/sent?page=2", "/sent?page=2"},
{"https://evil.com/", ""},
{"//evil.com/inbox", ""},
{"http://mail.lmve.net/inbox", ""},
{"javascript:alert(1)", ""},
{"/\\evil.com", "/\\evil.com"}, // 浏览器对 /\\ 的处理不一致,但不涉及外部协议跳转
}
for _, tc := range cases {
if got := safeRedirectPath(tc.in); got != tc.want {
t.Errorf("safeRedirectPath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}