mirror of
https://github.com/MengMengCode/MailCat.git
synced 2026-05-06 21:53:09 +08:00
feat: 安全加固 + GHCR CI/CD + 文档更新
安全修复: - 随机 Session Token (crypto/rand),替代可预测的固定 token - 登录速率限制 (5次/15分钟锁定),防暴力破解 - HMAC 恒定时间密码比较,防时序攻击 - 邮件 HTML 使用 sandbox iframe 渲染,防 XSS - 移除 Health 端点凭据泄露 (debug_info) - API Token 脱敏显示,移除明文返回 - 移除 URL 参数传 Token,仅支持 Authorization Header - CORS 收紧为同源策略 - 添加安全响应头 (X-Frame-Options/X-Content-Type-Options 等) - 请求体大小限制 10MB - 移除内存中明文密码存储 - Docker 运行时镜像固定版本 alpine:3.19 CI/CD: - 添加 GitHub Actions 自动构建并推送 Docker 镜像到 ghcr.io - 支持 amd64/arm64 多平台构建 其他: - 修复 Quoted-Printable 邮件解码 (换行符规范化) - 修复点击邮件行打开详情 (PrimeVue row-click 参数) - 前端登录密码 SHA-256 哈希后传输 - docker-compose.yml 镜像改为 ghcr.io - README 完全重写:GHCR 部署文档、安全特性、升级指南 - 数据库 schema 零变更,旧版本无感升级
This commit is contained in:
58
.github/workflows/docker-publish.yml
vendored
Normal file
58
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -48,7 +48,7 @@ RUN CGO_ENABLED=1 GOOS=linux \
|
||||
-o mailcat .
|
||||
|
||||
# 第三阶段:运行时镜像
|
||||
FROM alpine:latest
|
||||
FROM alpine:3.19
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apk --no-cache add ca-certificates sqlite tzdata
|
||||
|
||||
187
README.md
187
README.md
@@ -7,7 +7,8 @@
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/)
|
||||
[](https://github.com/MengMengCode/MailCat/pkgs/container/mailcat)
|
||||
[](https://github.com/MengMengCode/MailCat/actions)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -16,14 +17,15 @@
|
||||
## 📋 目录
|
||||
|
||||
- [✨ 功能特性](#-功能特性)
|
||||
- [🔒 安全特性](#-安全特性)
|
||||
- [🚀 快速开始](#-快速开始)
|
||||
- [源码构建运行](#1-源码构建运行)
|
||||
- [Docker Compose 部署](#2-docker-compose-部署)
|
||||
- [Docker Run 部署](#3-docker-run-部署)
|
||||
- [Docker Compose 部署(推荐)](#1-docker-compose-部署推荐)
|
||||
- [Docker Run 部署](#2-docker-run-部署)
|
||||
- [源码构建运行](#3-源码构建运行)
|
||||
- [☁️ Cloudflare Worker 配置](#️-cloudflare-worker-配置)
|
||||
- [📡 API 使用说明](#-api-使用说明)
|
||||
- [🖼️ 界面预览](#️-界面预览)
|
||||
- [⚙️ 配置说明](#️-配置说明)
|
||||
- [🔄 升级指南](#-升级指南)
|
||||
- [🤝 贡献指南](#-贡献指南)
|
||||
- [📄 许可证](#-许可证)
|
||||
|
||||
@@ -34,42 +36,34 @@
|
||||
MailCat 是一个基于 **Go + Vue.js** 的现代化邮件接收与管理系统,具有以下特性:
|
||||
|
||||
🔹 **轻量高效** - 基于 Go 语言开发,性能优异,资源占用低
|
||||
🔹 **现代化界面** - Vue.js 3 + Element Plus 构建的响应式 Web 界面
|
||||
🔹 **现代化界面** - Vue.js 3 + PrimeVue 构建的响应式 Web 界面
|
||||
🔹 **云端集成** - 完美集成 Cloudflare Worker,实现邮件转发
|
||||
🔹 **数据持久化** - 使用 SQLite3 数据库,轻量且可靠
|
||||
🔹 **RESTful API** - 提供完整的 API 接口,支持第三方集成
|
||||
🔹 **容器化部署** - 支持 Docker 一键部署,开箱即用
|
||||
🔹 **安全认证** - 支持 Token 认证和管理员密码保护
|
||||
🔹 **容器化部署** - 支持 Docker 一键部署,镜像托管于 GitHub Container Registry
|
||||
🔹 **安全认证** - 双端哈希密码传输、随机 Session、速率限制
|
||||
🔹 **分页查询** - 支持大量邮件的分页浏览和管理
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| **双端密码哈希** | 前端 SHA-256 哈希后传输,服务端 HMAC 安全比较,密码不明文传输 |
|
||||
| **随机 Session Token** | 每次登录生成加密安全的随机 Token,不可预测 |
|
||||
| **登录速率限制** | 5 次失败后锁定 15 分钟,防暴力破解 |
|
||||
| **XSS 防护** | 邮件 HTML 使用 `sandbox` iframe 渲染,隔离恶意脚本 |
|
||||
| **安全响应头** | 自动添加 `X-Content-Type-Options`、`X-Frame-Options`、`X-XSS-Protection` 等 |
|
||||
| **请求体限制** | 10MB 请求体大小限制,防止 DoS 攻击 |
|
||||
| **CORS 收紧** | 默认禁止跨域请求 |
|
||||
| **API Token 脱敏** | 管理面板仅显示脱敏后的 Token |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 源码构建运行
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/MengMengCode/MailCat.git
|
||||
cd mailcat
|
||||
|
||||
# 安装 Go 依赖
|
||||
go mod tidy
|
||||
|
||||
# 构建前端资源
|
||||
cd web/frontend
|
||||
npm install && npm run build
|
||||
cd ../..
|
||||
|
||||
# 启动服务
|
||||
go run main.go
|
||||
```
|
||||
|
||||
✅ 服务启动后访问:**http://server-ip:8080**
|
||||
|
||||
---
|
||||
|
||||
### 2. Docker Compose 部署
|
||||
### 1. Docker Compose 部署(推荐)
|
||||
|
||||
创建 `docker-compose.yml` 文件:
|
||||
|
||||
@@ -78,18 +72,23 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
mailcat:
|
||||
image: mengmengcode/mailcat:latest
|
||||
image: ghcr.io/mengmengcode/mailcat:latest
|
||||
container_name: mailcat
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
# API 认证令牌 - 用于 Cloudflare Worker 调用 API(请修改为安全的随机字符串)
|
||||
- MAILCAT_API_AUTH_TOKEN=your_secure_api_token_here
|
||||
# 管理员密码 - 用于 Web 管理界面登录(请修改为强密码)
|
||||
- MAILCAT_ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
# 时区设置
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
# 数据持久化 - SQLite 数据库文件
|
||||
- mailcat_data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -102,12 +101,14 @@ volumes:
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
✅ 服务启动后访问:**http://your-server-ip:8080**
|
||||
|
||||
---
|
||||
|
||||
### 3. Docker Run 部署
|
||||
### 2. Docker Run 部署
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
@@ -116,12 +117,36 @@ docker run -d \
|
||||
-p 8080:8080 \
|
||||
-e MAILCAT_API_AUTH_TOKEN=your_secure_api_token_here \
|
||||
-e MAILCAT_ADMIN_PASSWORD=your_secure_admin_password_here \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v mailcat_data:/app/data \
|
||||
mengmengcode/mailcat:latest
|
||||
ghcr.io/mengmengcode/mailcat:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 源码构建运行
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/MengMengCode/MailCat.git
|
||||
cd MailCat
|
||||
|
||||
# 构建前端资源
|
||||
cd web/frontend
|
||||
npm install && npm run build
|
||||
cd ../..
|
||||
|
||||
# 设置环境变量
|
||||
export MAILCAT_API_AUTH_TOKEN=your_secure_api_token_here
|
||||
export MAILCAT_ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# 安装 Go 依赖并启动
|
||||
go mod tidy
|
||||
go run main.go
|
||||
```
|
||||
|
||||
✅ 服务启动后访问:**http://localhost:8080**
|
||||
|
||||
## ☁️ Cloudflare Worker 配置
|
||||
|
||||
### 步骤 1:创建 Worker
|
||||
@@ -165,7 +190,7 @@ docker run -d \
|
||||
### 基础信息
|
||||
|
||||
- **基础 URL**:`https://your.domain.com/api/v1`
|
||||
- **认证方式**:Bearer Token 或 URL 参数
|
||||
- **认证方式**:`Authorization: Bearer <token>` 请求头
|
||||
- **数据格式**:JSON
|
||||
|
||||
### 邮件查询接口
|
||||
@@ -177,12 +202,6 @@ GET /api/v1/emails
|
||||
|
||||
#### 认证方式
|
||||
|
||||
**方式一:URL 参数**
|
||||
```
|
||||
https://your.domain.com/api/v1/emails?token=your_auth_token
|
||||
```
|
||||
|
||||
**方式二:请求头**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your_auth_token" \
|
||||
https://your.domain.com/api/v1/emails
|
||||
@@ -194,27 +213,25 @@ curl -H "Authorization: Bearer your_auth_token" \
|
||||
|--------|------|--------|------|------|
|
||||
| `page` | integer | `1` | ≥ 1 | 页码 |
|
||||
| `limit` | integer | `20` | 1-100 | 每页数量 |
|
||||
| `token` | string | - | - | 认证令牌(可选,如使用请求头认证) |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
**默认查询(第1页,20条)**
|
||||
```bash
|
||||
curl "https://your.domain.com/api/v1/emails?token=your_auth_token"
|
||||
curl -H "Authorization: Bearer your_auth_token" \
|
||||
"https://your.domain.com/api/v1/emails"
|
||||
```
|
||||
|
||||
**分页查询(第2页,50条)**
|
||||
```bash
|
||||
curl "https://your.domain.com/api/v1/emails?token=your_auth_token&page=2&limit=50"
|
||||
curl -H "Authorization: Bearer your_auth_token" \
|
||||
"https://your.domain.com/api/v1/emails?page=2&limit=50"
|
||||
```
|
||||
|
||||
**获取所有邮件(分页遍历)**
|
||||
**获取单封邮件详情**
|
||||
```bash
|
||||
# 第一次请求获取总数
|
||||
curl "https://your.domain.com/api/v1/emails?token=your_auth_token&limit=100"
|
||||
|
||||
# 根据返回的 total 字段计算总页数,然后逐页查询
|
||||
curl "https://your.domain.com/api/v1/emails?token=your_auth_token&page=2&limit=100"
|
||||
curl -H "Authorization: Bearer your_auth_token" \
|
||||
"https://your.domain.com/api/v1/emails/1"
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
@@ -261,30 +278,70 @@ curl "https://your.domain.com/api/v1/emails?token=your_auth_token&page=2&limit=1
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `MAILCAT_API_AUTH_TOKEN` | - | API 认证令牌(必填) |
|
||||
| `MAILCAT_ADMIN_PASSWORD` | - | 管理员密码(必填) |
|
||||
| `MAILCAT_PORT` | `8080` | 服务监听端口 |
|
||||
| `MAILCAT_DB_PATH` | `./data/mailcat.db` | SQLite 数据库文件路径 |
|
||||
| 变量名 | 必填 | 默认值 | 说明 |
|
||||
|--------|:----:|--------|------|
|
||||
| `MAILCAT_API_AUTH_TOKEN` | ✅ | - | API 认证令牌(Cloudflare Worker 使用) |
|
||||
| `MAILCAT_ADMIN_PASSWORD` | ✅ | - | 管理员登录密码 |
|
||||
| `MAILCAT_SERVER_PORT` | ❌ | `8080` | 服务监听端口 |
|
||||
| `MAILCAT_SERVER_HOST` | ❌ | `0.0.0.0` | 服务监听地址 |
|
||||
| `MAILCAT_DATABASE_PATH` | ❌ | `./data/emails.db` | SQLite 数据库文件路径 |
|
||||
| `TZ` | ❌ | `UTC` | 时区设置,建议 `Asia/Shanghai` |
|
||||
|
||||
### 配置文件
|
||||
|
||||
项目支持通过 [`config/config.yaml`](config/config.yaml) 进行配置:
|
||||
项目也支持通过 [`config/config.yaml`](config/config.yaml) 进行配置(环境变量优先级更高):
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8080
|
||||
port: "8080"
|
||||
host: "0.0.0.0"
|
||||
|
||||
database:
|
||||
path: "./data/mailcat.db"
|
||||
path: "./data/emails.db"
|
||||
|
||||
auth:
|
||||
api_token: "your_secure_api_token_here"
|
||||
admin_password: "your_secure_admin_password_here"
|
||||
api:
|
||||
auth_token: "" # 建议通过环境变量 MAILCAT_API_AUTH_TOKEN 设置
|
||||
|
||||
admin:
|
||||
password: "" # 建议通过环境变量 MAILCAT_ADMIN_PASSWORD 设置
|
||||
```
|
||||
|
||||
> ⚠️ **安全提醒**:请勿将真实的 Token 和密码提交到版本控制中,推荐使用环境变量或 `.env` 文件。
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级指南
|
||||
|
||||
### 从旧版本升级
|
||||
|
||||
MailCat 保证**数据库格式向后兼容**,升级不会影响已有数据。
|
||||
|
||||
**Docker 用户:**
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker pull ghcr.io/mengmengcode/mailcat:latest
|
||||
|
||||
# 重新创建容器(数据卷自动保留)
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**源码用户:**
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
cd web/frontend && npm install && npm run build && cd ../..
|
||||
go mod tidy
|
||||
go build -o mailcat .
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- ✅ 数据库 SQLite 文件完全兼容,无需迁移
|
||||
- ✅ 现有 Cloudflare Worker 配置无需修改
|
||||
- ⚠️ API 认证已移除 URL 参数传 Token 的方式(安全原因),请改用 `Authorization: Bearer <token>` 请求头
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
@@ -2,7 +2,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
mailcat:
|
||||
image: mengmengcode/mailcat:latest
|
||||
image: ghcr.io/mengmengcode/mailcat:latest
|
||||
container_name: mailcat
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mailcat/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,17 +18,41 @@ import (
|
||||
type AdminHandler struct {
|
||||
db *database.DB
|
||||
authToken string
|
||||
adminPassword string
|
||||
adminPasswordHash string
|
||||
|
||||
// 随机 session 管理
|
||||
sessions map[string]time.Time // token -> 过期时间
|
||||
sessionMu sync.RWMutex
|
||||
|
||||
// 登录速率限制
|
||||
loginAttempts map[string]*loginAttemptInfo
|
||||
loginMu sync.Mutex
|
||||
}
|
||||
|
||||
type loginAttemptInfo struct {
|
||||
count int
|
||||
firstAt time.Time
|
||||
lockedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
maxLoginAttempts = 5 // 最大尝试次数
|
||||
loginWindow = 5 * time.Minute // 计数窗口
|
||||
lockDuration = 15 * time.Minute // 锁定时长
|
||||
sessionMaxAge = 24 * time.Hour // Session 有效期
|
||||
)
|
||||
|
||||
func NewAdminHandler(db *database.DB, authToken, adminPassword string) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
db: db,
|
||||
authToken: authToken,
|
||||
adminPassword: adminPassword,
|
||||
h := &AdminHandler{
|
||||
db: db,
|
||||
authToken: authToken,
|
||||
adminPasswordHash: sha256Hex(adminPassword),
|
||||
sessions: make(map[string]time.Time),
|
||||
loginAttempts: make(map[string]*loginAttemptInfo),
|
||||
}
|
||||
// 启动后台清理过期 session
|
||||
go h.cleanExpiredSessions()
|
||||
return h
|
||||
}
|
||||
|
||||
// sha256Hex 计算字符串的 SHA-256 哈希值并返回十六进制字符串
|
||||
@@ -33,6 +61,96 @@ func sha256Hex(s string) string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// generateSessionToken 生成加密安全的随机 session token
|
||||
func generateSessionToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// cleanExpiredSessions 定期清理过期 session
|
||||
func (h *AdminHandler) cleanExpiredSessions() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
h.sessionMu.Lock()
|
||||
now := time.Now()
|
||||
for token, expiry := range h.sessions {
|
||||
if now.After(expiry) {
|
||||
delete(h.sessions, token)
|
||||
}
|
||||
}
|
||||
h.sessionMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// validateSession 验证 session token 是否有效
|
||||
func (h *AdminHandler) validateSession(token string) bool {
|
||||
h.sessionMu.RLock()
|
||||
defer h.sessionMu.RUnlock()
|
||||
expiry, exists := h.sessions[token]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(expiry)
|
||||
}
|
||||
|
||||
// checkRateLimit 检查登录速率限制,返回是否允许登录
|
||||
func (h *AdminHandler) checkRateLimit(ip string) (bool, int) {
|
||||
h.loginMu.Lock()
|
||||
defer h.loginMu.Unlock()
|
||||
|
||||
info, exists := h.loginAttempts[ip]
|
||||
if !exists {
|
||||
h.loginAttempts[ip] = &loginAttemptInfo{count: 0, firstAt: time.Now()}
|
||||
return true, maxLoginAttempts
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 检查是否在锁定期间
|
||||
if !info.lockedAt.IsZero() && now.Before(info.lockedAt.Add(lockDuration)) {
|
||||
remaining := int(info.lockedAt.Add(lockDuration).Sub(now).Seconds())
|
||||
return false, remaining
|
||||
}
|
||||
|
||||
// 超出计数窗口则重置
|
||||
if now.After(info.firstAt.Add(loginWindow)) {
|
||||
info.count = 0
|
||||
info.firstAt = now
|
||||
info.lockedAt = time.Time{}
|
||||
}
|
||||
|
||||
if info.count >= maxLoginAttempts {
|
||||
info.lockedAt = now
|
||||
return false, int(lockDuration.Seconds())
|
||||
}
|
||||
|
||||
return true, maxLoginAttempts - info.count
|
||||
}
|
||||
|
||||
// recordLoginAttempt 记录一次失败的登录尝试
|
||||
func (h *AdminHandler) recordLoginAttempt(ip string) {
|
||||
h.loginMu.Lock()
|
||||
defer h.loginMu.Unlock()
|
||||
|
||||
info, exists := h.loginAttempts[ip]
|
||||
if !exists {
|
||||
h.loginAttempts[ip] = &loginAttemptInfo{count: 1, firstAt: time.Now()}
|
||||
return
|
||||
}
|
||||
info.count++
|
||||
}
|
||||
|
||||
// resetLoginAttempts 登录成功后重置计数
|
||||
func (h *AdminHandler) resetLoginAttempts(ip string) {
|
||||
h.loginMu.Lock()
|
||||
defer h.loginMu.Unlock()
|
||||
delete(h.loginAttempts, ip)
|
||||
}
|
||||
|
||||
// AdminAuthMiddleware 管理员认证中间件
|
||||
func (h *AdminHandler) AdminAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -45,8 +163,8 @@ func (h *AdminHandler) AdminAuthMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用密码哈希值验证 session
|
||||
if session != "admin_logged_in_"+h.adminPasswordHash {
|
||||
// 使用随机 session token 验证
|
||||
if !h.validateSession(session) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Unauthorized",
|
||||
})
|
||||
@@ -60,6 +178,18 @@ func (h *AdminHandler) AdminAuthMiddleware() gin.HandlerFunc {
|
||||
|
||||
// Login 处理登录请求
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
// 速率限制检查
|
||||
allowed, remaining := h.checkRateLimit(clientIP)
|
||||
if !allowed {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Too many login attempts, please try again later",
|
||||
"retry_after": remaining,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var loginReq struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -71,17 +201,33 @@ func (h *AdminHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 前端发送的是 SHA-256 哈希后的密码,与服务端哈希比较
|
||||
if loginReq.Password != h.adminPasswordHash {
|
||||
// 前端发送的是 SHA-256 哈希后的密码,使用 HMAC 安全比较防止时序攻击
|
||||
if !hmac.Equal([]byte(loginReq.Password), []byte(h.adminPasswordHash)) {
|
||||
h.recordLoginAttempt(clientIP)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Invalid password",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用密码哈希值生成 session token,避免明文泄露
|
||||
sessionToken := "admin_logged_in_" + h.adminPasswordHash
|
||||
c.SetCookie("admin_session", sessionToken, 3600*24, "/", "", false, true)
|
||||
// 登录成功,重置速率限制
|
||||
h.resetLoginAttempts(clientIP)
|
||||
|
||||
// 生成加密安全的随机 session token
|
||||
sessionToken, err := generateSessionToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Internal server error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 存储 session
|
||||
h.sessionMu.Lock()
|
||||
h.sessions[sessionToken] = time.Now().Add(sessionMaxAge)
|
||||
h.sessionMu.Unlock()
|
||||
|
||||
c.SetCookie("admin_session", sessionToken, int(sessionMaxAge.Seconds()), "/", "", false, true)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Login successful",
|
||||
@@ -91,6 +237,19 @@ func (h *AdminHandler) Login(c *gin.Context) {
|
||||
|
||||
// Logout 处理登出请求
|
||||
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||
// 从存储中移除 session
|
||||
session := c.GetHeader("X-Admin-Session")
|
||||
if session == "" {
|
||||
if cookie, err := c.Cookie("admin_session"); err == nil {
|
||||
session = cookie
|
||||
}
|
||||
}
|
||||
if session != "" {
|
||||
h.sessionMu.Lock()
|
||||
delete(h.sessions, session)
|
||||
h.sessionMu.Unlock()
|
||||
}
|
||||
|
||||
c.SetCookie("admin_session", "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Logout successful",
|
||||
@@ -141,10 +300,16 @@ func (h *AdminHandler) GetAdminEmails(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetConfig 获取配置信息
|
||||
// GetConfig 获取配置信息(仅显示脱敏后的令牌)
|
||||
func (h *AdminHandler) GetConfig(c *gin.Context) {
|
||||
masked := h.authToken
|
||||
if len(masked) > 8 {
|
||||
masked = masked[:4] + "****" + masked[len(masked)-4:]
|
||||
} else {
|
||||
masked = "****"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"api_token": h.authToken,
|
||||
"api_token": masked,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,12 @@ func NewEmailHandler(db *database.DB, authToken string) *EmailHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// AuthMiddleware 验证API令牌
|
||||
// AuthMiddleware 验证API令牌(仅支持 Authorization 请求头)
|
||||
func (h *EmailHandler) AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("Authorization")
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
if token != "Bearer "+h.authToken && token != h.authToken {
|
||||
if token != "Bearer "+h.authToken {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Unauthorized",
|
||||
})
|
||||
@@ -272,6 +269,18 @@ func (h *EmailHandler) GetEmailByID(c *gin.Context) {
|
||||
htmlBody = textToHTML(body)
|
||||
}
|
||||
|
||||
// 最终兜底:检测并解码残留的 Quoted-Printable 编码
|
||||
if htmlBody != "" && utils.IsQuotedPrintable(htmlBody) {
|
||||
if decoded, err := utils.DecodeQuotedPrintable(htmlBody); err == nil {
|
||||
htmlBody = decoded
|
||||
}
|
||||
}
|
||||
if body != "" && utils.IsQuotedPrintable(body) {
|
||||
if decoded, err := utils.DecodeQuotedPrintable(body); err == nil {
|
||||
body = decoded
|
||||
}
|
||||
}
|
||||
|
||||
// 清理发件人和收件人字段
|
||||
from := cleanEmailAddress(email.From)
|
||||
to := cleanEmailAddress(email.To)
|
||||
@@ -294,9 +303,12 @@ func (h *EmailHandler) GetEmailByID(c *gin.Context) {
|
||||
|
||||
// isMIMEContent 检查内容是否为MIME格式
|
||||
func isMIMEContent(content string) bool {
|
||||
// 检查是否包含MIME边界标识符
|
||||
lines := strings.Split(content, "\r\n")
|
||||
// 检查是否包含MIME边界标识符,同时支持 \n 和 \r\n 换行
|
||||
lines := strings.FieldsFunc(content, func(r rune) bool {
|
||||
return r == '\n'
|
||||
})
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
// 查找以 -- 开头的边界线,且长度合理
|
||||
if strings.HasPrefix(line, "--") && len(line) > 10 {
|
||||
// 进一步验证是否包含Content-Type
|
||||
@@ -331,41 +343,10 @@ func cleanEmailAddress(address string) string {
|
||||
|
||||
// HealthCheck 健康检查端点
|
||||
func (h *EmailHandler) HealthCheck(c *gin.Context) {
|
||||
// 检查是否提供了认证信息
|
||||
token := c.GetHeader("Authorization")
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
// 如果提供了认证信息,验证它
|
||||
authenticated := false
|
||||
if token != "" {
|
||||
if token == "Bearer "+h.authToken || token == h.authToken {
|
||||
authenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"status": "ok",
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"message": "Email receiver service is running",
|
||||
"authenticated": authenticated,
|
||||
}
|
||||
|
||||
// 如果提供了无效的认证信息,返回401而不是403
|
||||
if token != "" && !authenticated {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"status": "error",
|
||||
"message": "Invalid authentication token",
|
||||
"authenticated": false,
|
||||
"debug_info": gin.H{
|
||||
"received_token": token,
|
||||
"expected_format": "Bearer " + h.authToken,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
// isBase64Content 检查内容是否是Base64编码
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"mailcat/internal/handlers"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -13,22 +14,45 @@ func SetupRouter(db *database.DB, authToken string, adminPassword string) *gin.E
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 请求体大小限制(10MB,防止大邮件 DoS)
|
||||
r.MaxMultipartMemory = 10 << 20
|
||||
|
||||
// 设置模板分隔符,避免与Vue.js语法冲突
|
||||
r.Delims("{[{", "}]}")
|
||||
|
||||
// 静态文件服务 - 仅服务Vue构建后的资源
|
||||
r.Static("/assets", "./web/dist/assets")
|
||||
|
||||
// 安全响应头中间件
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// 配置CORS
|
||||
// 配置CORS — 仅允许同源请求
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"}, // 允许所有来源
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||
AllowOrigins: []string{}, // 不允许跨域
|
||||
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Session"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
AllowCredentials: false,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
// 请求体大小限制中间件
|
||||
r.Use(func(c *gin.Context) {
|
||||
if c.Request.ContentLength > 10*1024*1024 { // 10MB
|
||||
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
|
||||
"error": "Request body too large",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// 创建邮件处理器
|
||||
emailHandler := handlers.NewEmailHandler(db, authToken)
|
||||
|
||||
@@ -320,6 +320,9 @@ func TryParseEmailContent(rawBody, htmlBody string, headersJSON string) (string,
|
||||
func ParseMIMEContent(content string) (*EmailContent, error) {
|
||||
result := &EmailContent{}
|
||||
|
||||
// 规范化换行符
|
||||
content = normalizeLineEndings(content)
|
||||
|
||||
// 检测MIME边界
|
||||
boundary := detectMIMEBoundary(content)
|
||||
if boundary == "" {
|
||||
@@ -400,6 +403,8 @@ func ParseMIMEContent(content string) (*EmailContent, error) {
|
||||
|
||||
// detectMIMEBoundary 检测MIME边界标识符
|
||||
func detectMIMEBoundary(content string) string {
|
||||
// 规范化换行符
|
||||
content = normalizeLineEndings(content)
|
||||
lines := strings.Split(content, "\r\n")
|
||||
|
||||
for _, line := range lines {
|
||||
@@ -475,4 +480,36 @@ func assignContentByType(result *EmailContent, content, contentType string) {
|
||||
result.HTMLBody = content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLineEndings 统一将换行符规范化为 \r\n
|
||||
func normalizeLineEndings(content string) string {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
content = strings.ReplaceAll(content, "\n", "\r\n")
|
||||
return content
|
||||
}
|
||||
|
||||
// IsQuotedPrintable 检查内容是否包含 Quoted-Printable 编码特征
|
||||
func IsQuotedPrintable(content string) bool {
|
||||
// 典型 QP 特征:=3D (编码的=号), =XX 十六进制编码
|
||||
qpPatterns := []string{"=3D", "=3d", "=20", "=C2", "=c2", "=E2", "=e2", "=09"}
|
||||
count := 0
|
||||
for _, p := range qpPatterns {
|
||||
if strings.Contains(content, p) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count >= 2
|
||||
}
|
||||
|
||||
// DecodeQuotedPrintable 解码 Quoted-Printable 编码的内容
|
||||
func DecodeQuotedPrintable(content string) (string, error) {
|
||||
normalized := normalizeLineEndings(content)
|
||||
reader := quotedprintable.NewReader(strings.NewReader(normalized))
|
||||
decoded, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return content, err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
@@ -65,9 +65,15 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- HTML 渲染 -->
|
||||
<!-- HTML 渲染(沙箱化 iframe) -->
|
||||
<div v-if="activeTab === 'html'" class="tab-pane">
|
||||
<div v-if="email.html_body && email.html_body.trim()" class="email-html-content" v-html="email.html_body"></div>
|
||||
<iframe
|
||||
v-if="email.html_body && email.html_body.trim()"
|
||||
class="email-html-iframe"
|
||||
sandbox="allow-same-origin"
|
||||
referrerpolicy="no-referrer"
|
||||
:srcdoc="email.html_body"
|
||||
></iframe>
|
||||
<div v-else class="empty-content">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<p>此邮件没有HTML内容</p>
|
||||
@@ -321,14 +327,13 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.email-html-content {
|
||||
background: var(--surface);
|
||||
.email-html-iframe {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
height: 60vh;
|
||||
border: none;
|
||||
border-radius: 0 0 var(--radius-large) var(--radius-large);
|
||||
padding: var(--spacing-xl);
|
||||
min-height: 350px;
|
||||
overflow: auto;
|
||||
line-height: 1.6;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.email-text-content,
|
||||
@@ -366,7 +371,7 @@ export default {
|
||||
|
||||
/* 滚动条样式 */
|
||||
.email-content::-webkit-scrollbar,
|
||||
.email-html-content::-webkit-scrollbar,
|
||||
.email-html-iframe::-webkit-scrollbar,
|
||||
.email-text-content::-webkit-scrollbar,
|
||||
.email-source-content::-webkit-scrollbar,
|
||||
.email-headers-content::-webkit-scrollbar {
|
||||
@@ -375,7 +380,7 @@ export default {
|
||||
}
|
||||
|
||||
.email-content::-webkit-scrollbar-track,
|
||||
.email-html-content::-webkit-scrollbar-track,
|
||||
.email-html-iframe::-webkit-scrollbar-track,
|
||||
.email-text-content::-webkit-scrollbar-track,
|
||||
.email-source-content::-webkit-scrollbar-track,
|
||||
.email-headers-content::-webkit-scrollbar-track {
|
||||
@@ -383,7 +388,7 @@ export default {
|
||||
}
|
||||
|
||||
.email-content::-webkit-scrollbar-thumb,
|
||||
.email-html-content::-webkit-scrollbar-thumb,
|
||||
.email-html-iframe::-webkit-scrollbar-thumb,
|
||||
.email-text-content::-webkit-scrollbar-thumb,
|
||||
.email-source-content::-webkit-scrollbar-thumb,
|
||||
.email-headers-content::-webkit-scrollbar-thumb {
|
||||
@@ -392,7 +397,7 @@ export default {
|
||||
}
|
||||
|
||||
.email-content::-webkit-scrollbar-thumb:hover,
|
||||
.email-html-content::-webkit-scrollbar-thumb:hover,
|
||||
.email-html-iframe::-webkit-scrollbar-thumb:hover,
|
||||
.email-text-content::-webkit-scrollbar-thumb:hover,
|
||||
.email-source-content::-webkit-scrollbar-thumb:hover,
|
||||
.email-headers-content::-webkit-scrollbar-thumb:hover {
|
||||
@@ -421,8 +426,9 @@ export default {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.email-html-content {
|
||||
padding: var(--spacing-md);
|
||||
.email-html-iframe {
|
||||
min-height: 300px;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.email-text-content,
|
||||
|
||||
Reference in New Issue
Block a user