Files
user-system/docs/code-review/SYSTEMATIC_FIX_PLAN.md

722 lines
20 KiB
Markdown
Raw Permalink 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.
# 系统性代码修复计划
**文档版本**: v2.0
**生成日期**: 2026-03-29
**计划状态**: 待确认
**专家审核**: 已通过安全、性能、代码质量三个专家 agent 审核
---
## 一、修复策略概述
### 1.1 修复阶段划分(已更新)
| 阶段 | 名称 | 优先级 | 问题数 | 预计工作量 |
|------|------|--------|--------|------------|
| Phase 0 | 安全紧急修复 | P0 | 6 | 1-2天 |
| Phase 1 | 核心安全修复 | P1 | 9 | 3-5天 |
| Phase 2 | 性能优化 | P2 | 5 | 2-3天 |
| Phase 3 | 代码质量提升 | P3 | 15+ | 5-7天 |
### 1.2 前置条件
1. **必须先合并 sub2api 最新代码** ⚠️
2. 建立代码审查 CI 流程
3. 准备回滚方案
### 1.3 专家审核总结
| 审核类别 | 方案可行性 | 需注意事项 |
|----------|-----------|------------|
| Phase 0 安全修复 | 90% | SEC-03 需数据迁移方案 |
| Phase 1 安全修复 | 85% | 部分需要用户重置流程 |
| Phase 2 性能优化 | 80% | PERF-01 SQL 需修正 |
| Phase 3 代码质量 | 90% | OAuth State 合并需审计调用方 |
---
## 二、Phase 0: 安全紧急修复 (P0)
### 问题清单
| ID | 问题 | 位置 | 严重程度 | 修复方案 | 状态 |
|----|------|------|----------|----------|------|
| SEC-01 | OAuth ValidateToken 始终返回 true | oauth.go:436 | 严重 | 删除或实现真正验证 | 待修复 |
| SEC-02 | 敏感操作验证绕过 | auth.go:1068-1102 | 严重 | 要求必须有密码或TOTP | 待修复 |
| SEC-03 | 恢复码明文存储 | auth.go:1117-1132 | 严重 | 使用 SHA256 哈希存储 | 待修复 |
| NEW-SEC-01 | Webhook SSRF 风险 | webhook.go:181 | 严重 | 添加 URL 验证和内网IP过滤 | 待修复 |
| SEC-05 | X-Forwarded-For IP 伪造 | ip_filter.go:50-78 | 高危 | 可信代理配置 | 待修复 |
| SEC-11 | rand.Read 错误忽略 | oauth_utils.go:30 | 高危 | **升级为P0** 处理错误返回值 | 待修复 |
### 修复步骤
#### Step 0.1: OAuth ValidateToken 修复 ⚠️ 专家建议删除无参数方法
```go
// 文件: internal/auth/oauth.go
// 专家建议: 直接删除无参数的 ValidateToken只保留 ValidateTokenWithProvider
// 推荐方案: 删除 ValidateToken 方法
// 或改为调用 GetUserInfo 进行实际验证
func (m *DefaultOAuthManager) ValidateToken(token string) (bool, error) {
if len(token) == 0 {
return false, nil
}
// 遍历所有 provider 进行验证
for _, provider := range m.GetEnabledProviders() {
if ok, _ := m.ValidateTokenWithProvider(provider.Type, token); ok {
return true, nil
}
}
return false, nil
}
```
**专家意见**: 原方法注释已说明无法进行真正验证,建议删除或重命名避免调用方误用。
---
#### Step 0.2: 敏感操作验证修复
```go
// 文件: internal/service/auth.go
// 方案: 当用户没有密码也没有TOTP时禁止执行敏感操作
func (s *AuthService) verifySensitiveAction(...) error {
hasPassword := strings.TrimSpace(user.Password) != ""
hasTOTP := user.TOTPEnabled && strings.TrimSpace(user.TOTPSecret) != ""
// ⚠️ 专家建议: 必须在验证逻辑之前检查
if !hasPassword && !hasTOTP {
return errors.New("请先设置密码或启用两步验证")
}
// 原有验证逻辑...
if password != "" {
if !auth.VerifyPassword(user.Password, password) {
return errors.New("当前密码不正确")
}
return nil
}
if code != "" {
return s.verifyTOTPCodeOrRecoveryCode(ctx, user, code)
}
return errors.New("password or TOTP verification is required")
}
```
**专家意见**: 需要确认 `user.Password` 字段存储方式(明文/哈希),因为 bcrypt 哈希不应该用 `!= ""` 判断是否设置。
---
#### Step 0.3: 恢复码哈希存储 ⚠️ 专家建议使用 SHA256 而非 bcrypt
```go
// 文件: internal/service/auth.go
// 专家建议: 恢复码是一次性使用场景,不适合 bcrypt适合可重复验证场景
// 使用 SHA256 HMAC 更适合
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func hashRecoveryCode(code string) (string, error) {
// 恢复码使用 SHA256 哈希(一次性使用场景不需要 cost factor
h := sha256.Sum256([]byte(code))
return hex.EncodeToString(h[:]), nil
}
func verifyRecoveryCode(code, hashedCode string) bool {
computedHash := sha256.Sum256([]byte(code))
return hmac.Equal(computedHash[:], []byte(hashedCode))
}
// 数据迁移: 需要添加迁移脚本处理已存在的明文恢复码
// 1. 读取所有用户的 TOTPRecoveryCodes
// 2. 逐个哈希并更新
```
**专家意见**: bcrypt 适合密码等需要反复验证的场景,恢复码一次性使用,用 SHA256 HMAC 更合适。
---
#### Step 0.4: Webhook SSRF 防护 ⚠️ 专家建议补充完整检查
```go
// 文件: internal/service/webhook.go
// 专家建议: 需要补充 localhost 检查和更完整的内网域名过滤
func isSafeURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" {
return false
}
// 只允许 http/https
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
host := u.Hostname()
// ⚠️ 禁止 localhost
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return false
}
// 检查内网 IP
if ip := net.ParseIP(host); ip != nil {
if isPrivateIP(ip) {
return false
}
}
// 检查内网域名
if strings.HasSuffix(host, ".internal") ||
strings.HasSuffix(host, ".local") ||
strings.HasSuffix(host, ".corp") ||
strings.HasSuffix(host, ".lan") {
return false
}
// ⚠️ 专家建议添加: 检查 DNS rebinding
// 如果 URL 解析后的 IP 是内网,则拒绝
// ...
return true
}
// 调用位置: webhook.go:181
func (s *WebhookService) sendWebhook(ctx context.Context, task *DeliveryTask) error {
if !isSafeURL(task.URL) {
return errors.New("webhook URL 不安全")
}
// ...
}
```
**测试用例要求**:
- `http://localhost/`
- `http://127.0.0.1/`
- `http://169.254.169.254/` (AWS) ❌
- `http://10.0.0.1/`
- `http://internal.corp/`
---
#### Step 0.5: IP 伪造防护 ⚠️ 专家建议添加可信代理列表
```go
// 文件: internal/api/middleware/ip_filter.go
// 专家建议: 添加可信代理 IP 列表配置
type IPFilterConfig struct {
TrustProxy bool // 是否信任 X-Forwarded-For
TrustedProxies []string // 可信代理 IP 列表
}
func realIP(c *gin.Context, cfg IPFilterConfig) string {
// 不信任代理时,直接用 TCP 连接 IP
if !cfg.TrustProxy {
return c.ClientIP()
}
xff := c.GetHeader("X-Forwarded-For")
if xff == "" {
return c.ClientIP()
}
// 从右到左遍历(最右边的是最后一次代理添加的)
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip == "" {
continue
}
// 检查是否在可信代理列表中
if !isTrustedProxy(ip, cfg.TrustedProxies) {
continue // 不是可信代理,跳过
}
// 是可信代理,返回这个 IP
if !isPrivateIP(ip) {
return ip
}
}
// 没有找到可信代理,使用客户端 IP
return c.ClientIP()
}
func isTrustedProxy(ip string, trusted []string) bool {
for _, t := range trusted {
if ip == t {
return true
}
}
return false
}
```
---
#### Step 0.6: rand.Read 错误处理 ⚠️ 升级为 P0
```go
// 文件: internal/auth/oauth_utils.go
// 专家意见: rand.Read 失败时返回空 state 会导致安全问题
func GenerateState() (string, error) {
b := make([]byte, 32)
// ⚠️ 必须处理错误
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate state failed: %w", err)
}
state := base64.URLEncoding.EncodeToString(b)
// ...
return state, nil
}
```
---
## 三、Phase 1: 核心安全修复 (P1)
### 问题清单
| ID | 问题 | 位置 | 修复方案 | 注意事项 |
|----|------|------|----------|----------|
| SEC-04 | TOTP 使用 SHA1 | totp.go:25 | 改为 SHA256 | ⚠️ 需用户重置流程 |
| SEC-06 | JTI 包含时间戳 | jwt.go:65 | 移除时间戳 | - |
| SEC-07 | OAuth State TOCTOU | oauth_utils.go:43-62 | 统一锁区域 | ⚠️ 先于 PERF-02 |
| SEC-08 | refresh 无限流 | router.go:108 | 添加限流中间件 | - |
| SEC-09 | CSRF 保护缺失 | auth.go:673-683 | 添加来源验证 | - |
| SEC-10 | Cookie 非 HttpOnly | auth.go:117 | 设置 HttpOnly=true | ⚠️ 需确认用途 |
| SEC-14 | Argon2 参数偏弱 | password.go:29 | 增加 iterations | ⚠️ 渐进式调整 |
| NEW-SEC-02 | Webhook context.Background | webhook.go:255 | 使用带超时 context | - |
| NEW-SEC-03 | 邮件发送用已取消 context | auth_email.go:86-90 | 使用独立 context | - |
### 修复步骤
#### Step 1.1: TOTP 改为 SHA256 ⚠️ 需用户重置流程
```go
// 文件: internal/auth/totp.go
const TOTPAlgorithm = otp.AlgorithmSHA256 // 从 SHA1 改为 SHA256
```
**用户重置流程方案**:
1. 添加数据库迁移标记 `totp_algorithm_upgrade = true`
2. 用户下次登录时提示"请重新设置两步验证"
3. 或提供管理员批量重置选项
---
#### Step 1.2: JTI 移除时间戳
```go
// 文件: internal/auth/jwt.go
func generateJTI() (string, error) {
b := make([]byte, 32) // 32字节熵足够
if _, err := cryptorand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil // 移除时间戳
}
```
---
#### Step 1.3: OAuth State TOCTOU 修复 ⚠️ 先于 PERF-02
```go
// 文件: internal/auth/oauth_utils.go
// 专家意见: 必须先修复 TOCTOU再添加清理 goroutine
func ValidateState(state string) bool {
stateStore.mu.Lock()
defer stateStore.mu.Unlock()
expireTime, ok := stateStore.states[state]
if !ok {
return false
}
if time.Now().After(expireTime) {
delete(stateStore.states, state)
return false
}
delete(stateStore.states, state)
return true
}
```
---
#### Step 1.4: refresh 添加限流
```go
// 文件: internal/api/router/router.go
authGroup.POST("/refresh",
r.rateLimitMiddleware.Refresh(), // 添加限流
r.authHandler.RefreshToken)
```
---
#### Step 1.5: Argon2 增加迭代次数 ⚠️ 渐进式调整
```go
// 文件: internal/auth/password.go
// 专家建议: 渐进式增加,先到 4观察性能影响后再到 5
return &Password{
memory: 64 * 1024,
iterations: 4, // 从 3 先增加到 4原来是 3OWASP 建议 >= 5
parallelism: 2,
saltLength: 16,
keyLength: 32,
}
```
---
## 四、Phase 2: 性能优化 (P2)
### 问题清单 ⚠️ 专家审核后调整
| ID | 问题 | 位置 | 修复方案 | 专家意见 |
|----|------|------|----------|----------|
| PERF-01 | 认证 4 次 DB 查询 | middleware/auth.go:131 | 合并为 JOIN 查询 | ⚠️ SQL 需修正 |
| PERF-02 | OAuth State 无清理 | oauth_utils.go:23 | 添加清理 goroutine | ⚠️ 必须先修 SEC-07 |
| PERF-03 | findUserForLogin 串行查询 | auth_runtime.go:32 | 使用 OR 查询 | 方案可行 |
| PERF-07 | goroutine 无超时 | auth.go:470 | 添加 5s 超时 | 方案可行 |
| PERF-08 | L1Cache 无清理 | l1.go | 添加定期清理 | 方案可行 |
**已移除**:
- PERF-04: 限流清理问题描述不准确SlidingWindow 已有过期机制
- PERF-09: AnomalyDetector 已有截断机制,不存在无上限
### 修复步骤
#### Step 2.1: 合并认证查询 ⚠️ SQL 需修正
```go
// 文件: internal/repository/user_role.go
// 专家修正: JOIN 顺序有误,需要修正
func (r *UserRoleRepository) GetUserRolesAndPermissions(ctx context.Context, userID int64) ([]*Role, []*Permission, error) {
// ⚠️ 修正后的 SQL
var results []struct {
RoleID int64
RoleName string
RoleCode string
PermissionID int64
PermissionCode string
}
err := r.db.WithContext(ctx).
Raw(`
SELECT DISTINCT r.id as role_id, r.name as role_name, r.code as role_code,
p.id as permission_id, p.code as permission_code
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
WHERE ur.user_id = ? AND r.status = 1
`, userID).
Scan(&results).Error
if err != nil {
return nil, nil, err
}
// 处理结果,构建 Role 和 Permission 列表...
}
```
---
#### Step 2.2: findUserForLogin OR 查询
```go
// 文件: internal/repository/user.go
// 方案: 单次查询
func (r *UserRepository) FindByAccount(ctx context.Context, account string) (*User, error) {
var user User
err := r.db.WithContext(ctx).
Where("username = ? OR email = ? OR phone = ?", account, account, account).
First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
```
---
#### Step 2.3: 添加超时 context
```go
// 文件: internal/service/auth.go
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.loginLogRepo.Create(ctx, loginRecord); err != nil {
log.Printf("auth: write login log failed...")
}
}()
```
---
## 五、Phase 3: 代码质量提升 (P3)
### 问题清单
| 类别 | 问题 | 修复方案 | 专家意见 |
|------|------|----------|----------|
| 代码重复 | OAuth State 重复 | 合并到 state.go | ⚠️ 需审计调用方 |
| 代码重复 | Handler 授权函数重复 | 提取到 authz.go | ⚠️ 统一错误处理 |
| 代码重复 | 分页逻辑重复 | 统一 PaginationParams | 方案可行 |
| 代码质量 | 魔法数字 | 定义常量 | 方案可行 |
| 代码质量 | 错误处理不一致 | 统一错误类型 | ⚠️ response.Error 可能存在 bug |
| 代码质量 | 正则重复编译 | 预编译 | 方案可行 |
### 修复步骤
#### Step 3.1: OAuth State 合并 ⚠️ 需审计调用方
```
修复步骤:
1. 审计所有调用方,确定使用的是哪个实现
- 搜索 GenerateState 调用
- 搜索 ValidateState 调用
- 搜索 stateStore 访问
2. 统一使用 state.go 的 StateManager
- 修改 GetStateManager() 初始化
- 确保 package 级别 stateStore 指向 StateManager
3. 删除 oauth_utils.go 中的重复代码
- 删除 stateStore 变量
- 删除重复的 GenerateState/ValidateState
- 保留其他 OAuth 辅助函数
4. 回归测试所有 OAuth 流程
```
---
#### Step 3.2: 分页逻辑统一
```go
// 创建 internal/api/handler/pagination.go
type PaginationParams struct {
Page int
PageSize int
Offset int
}
const (
DefaultPageSize = 20
MaxPageSize = 100
)
func ParsePageParams(c *gin.Context) PaginationParams {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(DefaultPageSize)))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > MaxPageSize {
pageSize = DefaultPageSize
}
return PaginationParams{
Page: page,
PageSize: pageSize,
Offset: (page - 1) * pageSize,
}
}
```
---
#### Step 3.3: 魔法数字定义常量
```go
// 创建 internal/pkg/constants/constants.go
package constants
import "time"
const (
// Password
Argon2Memory = 64 * 1024
Argon2Iterations = 4 // 渐进式调整
Argon2Parallelism = 2
Argon2SaltLength = 16
Argon2KeyLength = 32
// OAuth State
OAuthStateTTL = 10 * time.Minute
OAuthStateCleanupInterval = 5 * time.Minute
// Pagination
DefaultPageSize = 20
MaxPageSize = 100
// Login
MaxLoginAttempts = 5
LoginLockDuration = 15 * time.Minute
// Cache
DefaultUserCacheTTL = 15 * time.Minute
DefaultBlacklistTTL = 1 * time.Hour
)
```
---
#### Step 3.4: 正则预编译
```go
// 文件: internal/security/validator.go
// 在包级别预编译正则表达式
var (
sqlCommentRegex = regexp.MustCompile(`(?i);[\s]*--`)
blockCommentRegex = regexp.MustCompile(`(?i)/\*.*?\*/`)
xpProcRegex = regexp.MustCompile(`(?i)\bxp_\w+`)
execRegex = regexp.MustCompile(`(?i)\bexec[\s\(]`)
unionSelectRegex = regexp.MustCompile(`(?i)\bunion[\s]+select`)
// ... 更多预编译正则
)
func (v *Validator) SanitizeSQL(input string) string {
result := input
// 使用预编译的正则
result = sqlCommentRegex.ReplaceAllString(result, "")
result = blockCommentRegex.ReplaceAllString(result, "")
// ...
return result
}
```
---
## 六、新增安全问题(专家审核发现)
### 遗漏的 P1 问题
| ID | 问题 | 位置 | 严重程度 | 修复方案 |
|----|------|------|----------|----------|
| SEC-NEW-1 | 登录失败无限流 | auth.go | 高 | 添加限流 |
| SEC-NEW-2 | 密码复杂度验证不足 | password_policy.go | 中 | 添加强度检查 |
---
## 七、修复执行计划
### 7.1 时间安排
| 周次 | Phase | 任务 | 里程碑 |
|------|-------|------|---------|
| Week 1 | Phase 0 | 安全紧急修复 | 6 个 P0 问题修复完成 |
| Week 2 | Phase 1 | 核心安全修复 | 9 个 P1 问题修复完成 |
| Week 3 | Phase 2 | 性能优化 | 5 个性能问题修复完成 |
| Week 4-5 | Phase 3 | 代码质量提升 | 代码重复和质量问题修复 |
### 7.2 代码合并流程
```
⚠️ 前置条件: 合并 sub2api 最新代码
Phase 0:
1. git checkout -b fix/security-phase-0
2. 修复 SEC-01, SEC-02, SEC-03, NEW-SEC-01, SEC-05, SEC-11
3. 运行测试: go test ./...
4. 手动安全测试
5. Code Review
6. git merge to main
Phase 1:
1. git checkout -b fix/security-phase-1
2. 修复剩余安全问题
3. 测试 + Review + Merge
Phase 2 & 3: 同上
```
### 7.3 验证清单
每个 Phase 完成后需要验证:
- [ ] 所有单元测试通过 `go test ./...`
- [ ] 集成测试通过
- [ ] 手动安全测试通过
- [ ] 性能测试无退化(基准测试)
- [ ] 回归测试OAuth、登录、设备管理等核心流程
---
## 八、关键文件清单
| 文件 | 涉及问题 |
|------|----------|
| `internal/auth/oauth.go` | SEC-01 |
| `internal/service/auth.go` | SEC-02, SEC-03, PERF-07 |
| `internal/service/webhook.go` | NEW-SEC-01, NEW-SEC-02 |
| `internal/api/middleware/ip_filter.go` | SEC-05 |
| `internal/auth/oauth_utils.go` | SEC-07, SEC-11, PERF-02 |
| `internal/auth/totp.go` | SEC-04 |
| `internal/auth/jwt.go` | SEC-06 |
| `internal/auth/password.go` | SEC-14 |
| `internal/api/middleware/auth.go` | PERF-01 |
| `internal/repository/user_role.go` | PERF-01 |
| `internal/repository/user.go` | PERF-03 |
| `internal/cache/l1.go` | PERF-08 |
---
## 九、风险控制
### 9.1 回滚方案
每个修复需要同时提交:
1. 修复代码
2. 对应的单元测试
3. 回滚脚本(如需要)
### 9.2 监控告警
修复后需要监控:
- [ ] 认证失败率异常上升
- [ ] API 响应时间 P99 > 500ms
- [ ] 错误日志中安全相关关键词
- [ ] Webhook 投递失败率
### 9.3 专家审核意见汇总
| 问题 | 审核结论 |
|------|----------|
| SEC-01 | 方案可行,建议删除无参方法 |
| SEC-02 | 方案可行,需确认 Password 字段存储方式 |
| SEC-03 | 方案可行,建议用 SHA256 替代 bcrypt |
| SEC-05 | 方案可行,建议添加可信代理列表 |
| SEC-07 | 方案可行,必须先于 PERF-02 |
| PERF-01 | 方案可行SQL 需修正 |
| PERF-08 | 方案可行,需添加定期清理 goroutine |
---
*本计划由代码审查系统生成,已通过专家 agent 审核,待确认后执行*
*版本历史: v1.0 初稿, v2.0 专家审核后更新*