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:
@@ -3,16 +3,38 @@ 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
|
||||
@@ -140,8 +162,7 @@ func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
|
||||
}
|
||||
|
||||
if !rl.allow(ip) {
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
|
||||
tooManyRequests(ctx, window)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -156,8 +177,7 @@ func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Hand
|
||||
anonymousIPRL := newRateLimiter(limit*10, window)
|
||||
|
||||
tooMany := func(ctx flamego.Context) {
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
|
||||
tooManyRequests(ctx, window)
|
||||
}
|
||||
|
||||
return func(ctx flamego.Context, s session.Session) {
|
||||
|
||||
Reference in New Issue
Block a user