fix(security): 修复 P2 中危项(cookie/协议限速/路径遍历/默认口令/中继TLS/安全头/信息泄露)

- 会话 cookie 增加 Secure 标志;新增 [web].cookie_secure 配置
  (默认 true,仅本地 HTTP 调试关闭;缺失字段按安全默认处理)
- SMTP/IMAP/POP3 认证接入封禁体系(store.RecordAuthFailure 与 Web
  共用 ban_entries):失败计数达 ban.max_fail_attempts 即封禁 IP,
  已封禁 IP 拒绝认证,堵住协议层暴力破解
- 附件存储路径遍历防护重写:FullPath 白名单校验(UUID 文件名格式)
  + baseDir 前缀兜底,非法路径返回错误;Save 扩展名白名单化
- 初始管理员不再使用 admin/admin:密码取 MAILGO_ADMIN_PASSWORD 或
  随机生成并打印一次;新增 MustChangePassword 首登强制改密
  (管理员重置密码同样触发)
- 外发中继默认验证 TLS 证书(保护 AUTH 凭据,防 MITM),直投 MX
  保持机会式 TLS;新增 outbound.relay_tls_insecure 开关(默认 false)
- 新增安全响应头中间件:HSTS、X-Frame-Options DENY、nosniff、
  Referrer-Policy、基础 CSP(frame-ancestors 'none' 防点击劫持,
  connect-src/form-action 'self' 防数据外泄)
- LDAP/OAuth 登录错误统一为通用文案,原始错误只写日志,
  不再回显邮箱/内部细节(防用户枚举与信息泄露)
- 新增 25 个回归测试:cookie 标志、封禁阈值、路径遍历用例、
  中继 TLS 验证(自签证书 STARTTLS 集成)、安全头、OAuth 文案

部署注意:升级后所有会话失效需重新登录;若直接以 HTTP 提供
服务需显式配置 cookie_secure = false。
This commit is contained in:
2026-08-19 16:45:21 +08:00
parent c725d0b91e
commit 3f28ec20f4
26 files changed
+799 -101

No files matched your search

+47 -11
View File
@@ -4,11 +4,16 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/google/uuid"
)
// savedFileRe 匹配 Save 生成的文件名:UUID(小写十六进制)+ 可选白名单扩展名。
// 只允许这种格式的路径进入文件系统,杜绝路径遍历(../)、绝对路径等。
var savedFileRe = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(\.[A-Za-z0-9._-]{1,32})?$`)
// AttachmentStorage handles file operations for email attachments on disk.
type AttachmentStorage struct {
baseDir string // cfg.Storage.AttachDir
@@ -19,6 +24,24 @@ func NewAttachmentStorage(baseDir string) *AttachmentStorage {
return &AttachmentStorage{baseDir: baseDir}
}
// safeExt 提取并白名单化文件扩展名:只保留字母数字与 ._-,最长 32 字符。
// 非法字符(含 CR/LF、路径分隔符)直接丢弃扩展名。
func safeExt(filename string) string {
ext := filepath.Ext(filename)
if len(ext) > 33 {
return ""
}
for _, r := range ext {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '.', r == '_', r == '-':
default:
return ""
}
}
return ext
}
// Save writes attachment data to disk and returns the relative file path.
// The filename is generated as {uuid}{ext} to avoid collisions.
func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
@@ -27,8 +50,8 @@ func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
return "", fmt.Errorf("创建附件目录失败: %w", err)
}
// Generate a unique filename with the original extension
ext := filepath.Ext(filename)
// Generate a unique filename with a sanitized extension
ext := safeExt(filename)
uniqueName := uuid.New().String() + ext
fullPath := filepath.Join(s.baseDir, uniqueName)
@@ -41,7 +64,10 @@ func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
// Read reads attachment data from disk given a relative path.
func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
fullPath := s.FullPath(relPath)
fullPath, err := s.FullPath(relPath)
if err != nil {
return nil, err
}
data, err := os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("读取附件文件失败: %w", err)
@@ -51,19 +77,29 @@ func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
// Delete removes an attachment file from disk given a relative path.
func (s *AttachmentStorage) Delete(relPath string) error {
fullPath := s.FullPath(relPath)
fullPath, err := s.FullPath(relPath)
if err != nil {
return err
}
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除附件文件失败: %w", err)
}
return nil
}
// FullPath returns the absolute path for a given relative path.
func (s *AttachmentStorage) FullPath(relPath string) string {
// Prevent directory traversal attacks
cleanRel := filepath.Clean(relPath)
if strings.HasPrefix(cleanRel, "..") {
cleanRel = strings.TrimPrefix(cleanRel, "../")
// FullPath returns the absolute path for a relative path produced by Save.
// Paths that do not match the saved-file format (traversal attempts,
// absolute paths, unrelated names) are rejected with an error so they can
// never escape baseDir.
func (s *AttachmentStorage) FullPath(relPath string) (string, error) {
if !savedFileRe.MatchString(relPath) {
return "", fmt.Errorf("非法的附件路径: %q", relPath)
}
return filepath.Join(s.baseDir, cleanRel)
// 兜底校验:解析后的路径必须仍在 baseDir 内
fullPath := filepath.Join(s.baseDir, relPath)
if !strings.HasPrefix(fullPath, filepath.Clean(s.baseDir)+string(os.PathSeparator)) {
return "", fmt.Errorf("附件路径越界: %q", relPath)
}
return fullPath, nil
}
+97
View File
@@ -0,0 +1,97 @@
package storage
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
)
// TestFullPathRejectsTraversal 验证路径遍历/绝对路径等恶意输入被拒绝。
func TestFullPathRejectsTraversal(t *testing.T) {
s := NewAttachmentStorage(filepath.Join(t.TempDir(), "attachments"))
valid := uuid.New().String() + ".pdf"
bad := []string{
"../secret.txt",
"../../etc/passwd",
"..",
"....//x",
"/etc/passwd",
"a/../b.txt",
"sub/file.png",
"",
".", "..\\..\\x", // windows style
"00000000-0000-0000-0000-000000000000.exe\r\nBcc: x@y.com",
"garbage",
"00000000-0000-0000-0000-000000000000.%2e%2e",
}
for _, p := range bad {
if _, err := s.FullPath(p); err == nil {
t.Errorf("FullPath(%q) should be rejected", p)
}
}
// 合法文件名必须通过
full, err := s.FullPath(valid)
if err != nil {
t.Fatalf("FullPath(%q) rejected: %v", valid, err)
}
if !strings.HasPrefix(full, s.baseDir+string(os.PathSeparator)) {
t.Fatalf("FullPath(%q) = %q escapes baseDir", valid, full)
}
}
// TestSaveSanitizesExtension 验证恶意扩展名不会进入文件名。
func TestSaveSanitizesExtension(t *testing.T) {
s := NewAttachmentStorage(filepath.Join(t.TempDir(), "attachments"))
// 换行/路径分隔符等非法字符的扩展名应被丢弃
rel, err := s.Save("evil.pdf\r\nBcc: x@y.com", []byte("data"))
if err != nil {
t.Fatalf("Save: %v", err)
}
if strings.ContainsAny(rel, "\r\n/\\") {
t.Fatalf("saved name contains dangerous chars: %q", rel)
}
if !savedFileRe.MatchString(rel) {
t.Fatalf("saved name %q does not match allowed pattern", rel)
}
// 后续 Read 应能按返回的路径读取
if _, err := s.Read(rel); err != nil {
t.Fatalf("Read after Save: %v", err)
}
// 正常扩展名保留
rel2, err := s.Save("report.pdf", []byte("data"))
if err != nil {
t.Fatalf("Save: %v", err)
}
if !strings.HasSuffix(rel2, ".pdf") {
t.Fatalf("extension lost: %q", rel2)
}
}
// TestReadDeleteRoundTrip 正常读写删流程。
func TestReadDeleteRoundTrip(t *testing.T) {
dir := t.TempDir()
s := NewAttachmentStorage(filepath.Join(dir, "attachments"))
rel, err := s.Save("a.txt", []byte("hello"))
if err != nil {
t.Fatalf("Save: %v", err)
}
data, err := s.Read(rel)
if err != nil || string(data) != "hello" {
t.Fatalf("Read = %q, %v", data, err)
}
if err := s.Delete(rel); err != nil {
t.Fatalf("Delete: %v", err)
}
// 删除后路径仍然合法(删除不存在文件不算错误)
if err := s.Delete(rel); err != nil {
t.Fatalf("Delete again: %v", err)
}
}