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:
tamakyi
2026-10-27 12:37:40 +08:00
parent a3cf99a8da
commit 7802bb2078
32 changed files with 1579 additions and 145 deletions
+56 -12
View File
@@ -217,6 +217,14 @@ func formatFeatureTime(t *time.Time) string {
return t.Format("2006-01-02 15:04")
}
// pagePasswordGuard 页面访问密码的爆破防护(P1-2)。
//
// 现实参数:该入口限流 10 次/分钟/IP,4 位纯数字(10^4)约 17 小时即可穷举完,
// 而页面密码正是产品对外宣传的访问控制。改成「10 分钟内错 5 次 → 锁 10 分钟」后,
// 同样的穷举被拉到数周量级,且锁定期间根本不比对密码。
// 锁定期满会自动清零(不会一次误触就永久锁死),语义见 shared.AttemptLimiter。
var pagePasswordGuard = shared.NewAttemptLimiter(5, 10*time.Minute, 10*time.Minute)
func PasswordPage(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
@@ -243,17 +251,33 @@ func VerifyPagePassword(ctx context.Context) {
ctx.Redirect("/")
return
}
// 爆破防护(P1-2):同一「箱主 + 来源 IP」在窗口内错够次数就锁一段时间。
// 键里带 pageUser.ID,避免攻击者用自己箱子的失败次数把别人的密码入口锁死。
// 锁定期间直接拒绝、不比对密码,免得每次失败都白算一遍哈希。
guardKey := fmt.Sprintf("page_pwd:%d:%s", pageUser.ID, shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)))
if blocked, remain := pagePasswordGuard.Blocked(guardKey); blocked {
minutes := int(remain/time.Minute) + 1
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errPasswordLocked", minutes))
ctx.Redirect("/_/" + domain + "/password")
return
}
password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) {
pagePasswordGuard.Reset(guardKey)
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
ctx.Session.Set(passKey, true)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页
next := ctx.Request().FormValue("next")
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") {
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
// 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
// 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
next := shared.SafeRedirectTarget(ctx.Request().FormValue("next"))
if next == "" {
next = "/_/" + domain
}
ctx.Redirect(next)
} else {
pagePasswordGuard.Fail(guardKey)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword"))
ctx.Redirect("/_/" + domain + "/password")
}
@@ -341,16 +365,25 @@ func ListAPI(ctx context.Context) error {
return ctx.JSON(resp)
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问密码检查
if pageUser.AskPassword != "" {
if !pageUser.CheckAskPassword(f.AskPassword) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list")
return
}
// askPasswordDenied 校验提问密码,失败时写入表单错误并返回 true(调用方直接 return)。
//
// ⚠️ 必须排在验证码校验之后调用。旧实现把它放在 New() 的第一行,于是「猜密码」的请求
// 完全不消耗验证码:实测错误密码 + 完全无效的验证码,返回的是「提问密码错误」,
// 验证码在这个入口等于不存在 —— 30 次/分钟 × 无成本,4 位数字约 6 小时可破。
// 挪到验证码之后,每次猜密码都要先解一道题(验证码接口自身还有 20/分钟/IP 的限额)。
func askPasswordDenied(ctx context.Context, pageUser *db.User, f form.NewQuestion) bool {
if pageUser.AskPassword == "" {
return false
}
if pageUser.CheckAskPassword(f.AskPassword) {
return false
}
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list")
return true
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
@@ -458,6 +491,12 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
// 私聊与提问共用同一个提问密码,这里不能漏,否则发起私聊可绕过它。
if askPasswordDenied(ctx, pageUser, f) {
return
}
// 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit"))
@@ -578,6 +617,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return
}
// 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
if askPasswordDenied(ctx, pageUser, f) {
return
}
content := f.Content
isPrivate := f.IsPrivate != ""