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 ./... 全通过。
84 lines
3.0 KiB
Go
84 lines
3.0 KiB
Go
// 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 user
|
|
|
|
import (
|
|
"unicode/utf8"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/context"
|
|
"github.com/tamakyi/TamaBox/internal/db"
|
|
"github.com/tamakyi/TamaBox/internal/form"
|
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
|
)
|
|
|
|
// rejectTooShortPassword 二次校验密码最小长度(P1-2)。
|
|
//
|
|
// form 层已经有 minlen 标签,这里再挡一次是为了防止将来出现绕过 form.Bind 的调用点
|
|
// —— 密码强度一旦漏掉就是「页面访问密码形同虚设」,值得重复一道。
|
|
// 按 rune 计数,与 govalid 的 minlen 口径一致(中文密码不会被按字节数放大)。
|
|
func rejectTooShortPassword(ctx context.Context, password string) bool {
|
|
if password == "" {
|
|
return false // 留空 = 关闭该密码
|
|
}
|
|
if utf8.RuneCountInString(password) >= form.PasswordMinLen {
|
|
return false
|
|
}
|
|
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "form.profile.passwordLen"))
|
|
ctx.Redirect(returnTab(ctx))
|
|
return true
|
|
}
|
|
|
|
// UpdatePagePassword 更新页面访问密码
|
|
func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
|
|
// 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止,
|
|
// 不触碰现有密码。govalid 校验失败(如密码超长)同样在此拦截,避免绕过前端限制。
|
|
// 该表单已随「内容防护」聚合迁入 security 面板,回跳地址跟随表单携带的 ?tab=。
|
|
if abortOnFormError(ctx, returnTab(ctx)) {
|
|
return
|
|
}
|
|
if rejectTooShortPassword(ctx, f.Password) {
|
|
return
|
|
}
|
|
|
|
if err := db.Users.UpdatePagePassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update page password")
|
|
ctx.SetInternalErrorFlash()
|
|
} else {
|
|
if f.Password == "" {
|
|
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.pagePwdOff"))
|
|
} else {
|
|
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.pagePwdUpdated"))
|
|
}
|
|
}
|
|
ctx.Redirect(returnTab(ctx))
|
|
}
|
|
|
|
// UpdateAskPassword 更新提问密码
|
|
func UpdateAskPassword(ctx context.Context, f form.UpdateAskPassword) {
|
|
// 同上:反序列化失败或校验失败时零值 Password="" 会把提问密码静默解除。
|
|
// 回跳地址跟随表单携带的 ?tab=(该表单已迁入「内容防护」security 面板)。
|
|
if abortOnFormError(ctx, returnTab(ctx)) {
|
|
return
|
|
}
|
|
if rejectTooShortPassword(ctx, f.Password) {
|
|
return
|
|
}
|
|
|
|
if err := db.Users.UpdateAskPassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
|
|
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update ask password")
|
|
ctx.SetInternalErrorFlash()
|
|
} else {
|
|
if f.Password == "" {
|
|
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.askPwdOff"))
|
|
} else {
|
|
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.askPwdUpdated"))
|
|
}
|
|
}
|
|
// 保存后回到提交时所在的面板(内容防护面板)
|
|
ctx.Redirect(returnTab(ctx))
|
|
}
|