- 会话 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。
106 lines
3.3 KiB
Go
106 lines
3.3 KiB
Go
package storage
|
|
|
|
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
|
|
}
|
|
|
|
// NewAttachmentStorage creates a new AttachmentStorage with the given base directory.
|
|
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) {
|
|
// Ensure the base directory exists
|
|
if err := os.MkdirAll(s.baseDir, 0755); err != nil {
|
|
return "", fmt.Errorf("创建附件目录失败: %w", err)
|
|
}
|
|
|
|
// Generate a unique filename with a sanitized extension
|
|
ext := safeExt(filename)
|
|
uniqueName := uuid.New().String() + ext
|
|
|
|
fullPath := filepath.Join(s.baseDir, uniqueName)
|
|
if err := os.WriteFile(fullPath, data, 0644); err != nil {
|
|
return "", fmt.Errorf("写入附件文件失败: %w", err)
|
|
}
|
|
|
|
return uniqueName, nil
|
|
}
|
|
|
|
// Read reads attachment data from disk given a relative path.
|
|
func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
|
|
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)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Delete removes an attachment file from disk given a relative path.
|
|
func (s *AttachmentStorage) Delete(relPath string) error {
|
|
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 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)
|
|
}
|
|
|
|
// 兜底校验:解析后的路径必须仍在 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
|
|
}
|