- 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)全部修复完成。
130 lines
4.2 KiB
Go
130 lines
4.2 KiB
Go
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 #12:Referer 开放重定向防护。
|
||
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)
|
||
}
|
||
}
|
||
}
|