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 ./... 全通过。
204 lines
5.7 KiB
Go
204 lines
5.7 KiB
Go
package route
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/flamego/flamego"
|
||
"github.com/flamego/session"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||
"github.com/tamakyi/TamaBox/internal/shared"
|
||
"github.com/tamakyi/TamaBox/route/vote"
|
||
)
|
||
|
||
// tooManyRequests 是 429 的唯一出口。
|
||
//
|
||
// 原来两处中间件各写一遍,只有一句英文硬编码 —— 既没走 i18n(中文站给访客看英文),
|
||
// 也没有 Retry-After,客户端只能靠猜什么时候可以重试(多数会立刻重试,把限流放大)。
|
||
// Retry-After 取窗口长度:滑动窗口下真实可重试时间可能更早,这里是安全上界。
|
||
//
|
||
// 语言固定用站点默认:限流中间件跑在 Contexter 之前,此时拿不到已解析的界面语言,
|
||
// 而 429 的 body 通常由前端 JS 展示、很少直接被阅读。
|
||
func tooManyRequests(ctx flamego.Context, window time.Duration) {
|
||
w := ctx.ResponseWriter()
|
||
retry := int(window.Seconds())
|
||
if retry < 1 {
|
||
retry = 1
|
||
}
|
||
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
w.WriteHeader(http.StatusTooManyRequests)
|
||
_, _ = w.Write([]byte(i18n.T(i18n.Default, "common.tooManyRequests")))
|
||
}
|
||
|
||
// rateLimiter provides a simple in-memory rate limiter using a sliding window.
|
||
type rateLimiter struct {
|
||
mu sync.Mutex
|
||
visitors map[string][]time.Time
|
||
limit int
|
||
window time.Duration
|
||
}
|
||
|
||
// ---------- 全局清理 ----------
|
||
//
|
||
// 所有 rateLimiter 实例注册到全局注册表,由单个常驻 goroutine 统一清理,
|
||
// 避免每个中间件实例各自启动一个永久 goroutine(旧实现会随路由数量线性泄漏 goroutine)。
|
||
|
||
var (
|
||
rateLimitersMu sync.Mutex
|
||
rateLimiters = make(map[*rateLimiter]struct{})
|
||
rateLimiterClean sync.Once
|
||
)
|
||
|
||
func registerRateLimiter(rl *rateLimiter) {
|
||
rateLimitersMu.Lock()
|
||
rateLimiters[rl] = struct{}{}
|
||
rateLimitersMu.Unlock()
|
||
|
||
// 仅启动一次全局清理循环
|
||
rateLimiterClean.Do(func() {
|
||
go func() {
|
||
for {
|
||
time.Sleep(5 * time.Minute)
|
||
rateLimitersMu.Lock()
|
||
for rl := range rateLimiters {
|
||
rl.cleanup()
|
||
}
|
||
rateLimitersMu.Unlock()
|
||
}
|
||
}()
|
||
})
|
||
}
|
||
|
||
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||
rl := &rateLimiter{
|
||
visitors: make(map[string][]time.Time),
|
||
limit: limit,
|
||
window: window,
|
||
}
|
||
registerRateLimiter(rl)
|
||
return rl
|
||
}
|
||
|
||
func (rl *rateLimiter) allow(ip string) bool {
|
||
rl.mu.Lock()
|
||
defer rl.mu.Unlock()
|
||
|
||
// 恶意伪造 X-Forwarded-For 可撑爆 visitors map,超过阈值时快速清理
|
||
// 窗口内无活动的 key(其最新时间戳已过期)
|
||
if len(rl.visitors) > 10000 {
|
||
cutoff2 := time.Now().Add(-rl.window)
|
||
for ip, ts := range rl.visitors {
|
||
if len(ts) == 0 || ts[len(ts)-1].Before(cutoff2) {
|
||
delete(rl.visitors, ip)
|
||
}
|
||
}
|
||
}
|
||
|
||
now := time.Now()
|
||
cutoff := now.Add(-rl.window)
|
||
|
||
times := rl.visitors[ip]
|
||
// Remove expired entries.
|
||
valid := times[:0]
|
||
for _, t := range times {
|
||
if t.After(cutoff) {
|
||
valid = append(valid, t)
|
||
}
|
||
}
|
||
|
||
if len(valid) >= rl.limit {
|
||
rl.visitors[ip] = valid
|
||
return false
|
||
}
|
||
|
||
valid = append(valid, now)
|
||
rl.visitors[ip] = valid
|
||
return true
|
||
}
|
||
|
||
func (rl *rateLimiter) cleanup() {
|
||
rl.mu.Lock()
|
||
defer rl.mu.Unlock()
|
||
|
||
cutoff := time.Now().Add(-rl.window)
|
||
for ip, times := range rl.visitors {
|
||
valid := times[:0]
|
||
for _, t := range times {
|
||
if t.After(cutoff) {
|
||
valid = append(valid, t)
|
||
}
|
||
}
|
||
if len(valid) == 0 {
|
||
delete(rl.visitors, ip)
|
||
} else {
|
||
rl.visitors[ip] = valid
|
||
}
|
||
}
|
||
}
|
||
|
||
// rateLimitMiddleware returns a Flamego handler that applies rate limiting.
|
||
// limit: max requests per window; window: time window duration.
|
||
//
|
||
// 匿名 / 登录分级限流:登录用户(session 中存在 uid)使用 3 倍额度,
|
||
// 未登录访客使用基础额度。分级使用独立限流器统计,互不污染。
|
||
func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
|
||
anonymousRL := newRateLimiter(limit, window)
|
||
loggedRL := newRateLimiter(limit*3, window)
|
||
|
||
return func(ctx flamego.Context, s session.Session) {
|
||
// 统一通过 shared.ResolveClientIP 解析,trust_proxy=false 时忽略伪造的代理头。
|
||
// 再经 shared.LimitKey 归一化:IPv4 原样,IPv6 聚合到 /64,
|
||
// 防止在单个 /64 内轮换海量地址绕过阈值(详见 LimitKey 注释)。
|
||
ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request))
|
||
|
||
rl := anonymousRL
|
||
if _, ok := s.Get("uid").(uint); ok {
|
||
rl = loggedRL
|
||
}
|
||
|
||
if !rl.allow(ip) {
|
||
tooManyRequests(ctx, window)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// voteSubmitRateLimitMiddleware 对投票提交同时执行两层限流:
|
||
// 匿名身份层按 token 计数,IP 层使用更高阈值作为批量换 token 的风控兜底。
|
||
// 这样共享公网出口的多个匿名浏览器不会共享低阈值,同时仍能限制异常突发流量。
|
||
func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
|
||
anonymousTokenRL := newRateLimiter(limit, window)
|
||
loggedUserRL := newRateLimiter(limit*3, window)
|
||
anonymousIPRL := newRateLimiter(limit*10, window)
|
||
|
||
tooMany := func(ctx flamego.Context) {
|
||
tooManyRequests(ctx, window)
|
||
}
|
||
|
||
return func(ctx flamego.Context, s session.Session) {
|
||
r := ctx.Request().Request
|
||
if uid, ok := s.Get("uid").(uint); ok && uid > 0 {
|
||
if !loggedUserRL.allow(fmt.Sprintf("user:%d", uid)) {
|
||
tooMany(ctx)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 匿名请求始终经过高阈值 IP 兜底;共享出口下每个有效 token 仍独立计数。
|
||
// IP 键同样经 LimitKey 聚合,避免 IPv6 轮换绕过兜底阈值。
|
||
if !anonymousIPRL.allow("ip:" + shared.LimitKey(shared.ResolveClientIP(r))) {
|
||
tooMany(ctx)
|
||
return
|
||
}
|
||
key := vote.VoterTokenRateLimitKey(r)
|
||
if key != "" && !anonymousTokenRL.allow("token:"+key) {
|
||
tooMany(ctx)
|
||
}
|
||
}
|
||
}
|