fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
P1: - 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的 image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉 - 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟 - 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies) 时采信,公网对端带来的权威头一律忽略 P2/P3: - 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a 解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀 - 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕 - CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与 按 sentry_dsn 条件放行的 Sentry CDN - /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到 静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS) - 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案 - token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type 全局复查补的两处: - 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息 - 页面密码 next 与登录 to 同源的反斜杠外跳 验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、 verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BanRecord 是一次自动封禁的落库记录。
|
||||
//
|
||||
// 为什么需要它:自动封禁的生效状态是**进程内存**里的(见 internal/security/autoban),
|
||||
// 重启即清零,外部完全看不见。线上出现过「邮件说封了、实际还能访问」的排查困境 ——
|
||||
// 当时没有任何东西能说明封的是哪个键、因为什么、到什么时候解除,只能靠翻日志猜。
|
||||
//
|
||||
// 这张表是「封禁事实」的账本,不参与判定:IsBanned 仍然只读内存,
|
||||
// 表只用于事后解释与取证。写入失败绝不能影响封禁动作本身。
|
||||
//
|
||||
// 增长:每个键在封禁期内不会重复建封(autoban 命中已封禁直接拒绝),
|
||||
// 所以行数量级 = 封禁次数,不需要自动清理。要清可手动 DELETE。
|
||||
type BanRecord struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
|
||||
// BanKey 与 autoban.IsBanned 的判断口径完全一致(shared.LimitKey,
|
||||
// IPv6 折成 /64),否则账本与判定会各说一套。
|
||||
BanKey string `gorm:"size:64;index" json:"ban_key"`
|
||||
// Reason 是给人看的原因文案(敏感路径探测 / 蜜罐提交凭据)。
|
||||
Reason string `gorm:"size:64" json:"reason"`
|
||||
// DurationMinutes 与 ExpiresAt 一起回答「封多久、什么时候自动解除」。
|
||||
DurationMinutes int `gorm:"not null;default:0" json:"duration_minutes"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
}
|
||||
|
||||
// TableName 固定表名,避免 GORM 复数化差异。
|
||||
func (BanRecord) TableName() string { return "ban_records" }
|
||||
|
||||
// BanRecords 自动封禁记录的全局 store,由 db.Init 装配。
|
||||
var BanRecords BanRecordStore
|
||||
|
||||
// BanRecordStore 封禁记录的读写。
|
||||
type BanRecordStore interface {
|
||||
// Create 追加一条封禁记录。
|
||||
Create(ctx context.Context, record *BanRecord) error
|
||||
// ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。
|
||||
ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error)
|
||||
}
|
||||
|
||||
type banRecords struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewBanRecordsStore 创建封禁记录 store。
|
||||
func NewBanRecordsStore(db *gorm.DB) BanRecordStore {
|
||||
return &banRecords{db: db}
|
||||
}
|
||||
|
||||
func (s *banRecords) Create(ctx context.Context, record *BanRecord) error {
|
||||
return s.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
func (s *banRecords) ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error) {
|
||||
var records []*BanRecord
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("created_at >= ?", since).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&records).Error
|
||||
return records, err
|
||||
}
|
||||
Reference in New Issue
Block a user