Files
tamabox/route/auth/register.go
T
tamakyi 7802bb2078 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 ./... 全通过。
2026-10-27 12:37:40 +08:00

122 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
)
func Register(ctx context.Context) {
ctx.Success("auth/register")
}
func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/register")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/register")
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/register")
return
}
if err := db.Users.Create(ctx.Request().Context(), db.CreateUserOptions{
Name: f.Name,
Password: f.Password,
Email: f.Email,
Avatar: conf.Upload.DefaultAvatarURL,
Domain: f.Domain,
Background: conf.Upload.DefaultBackground,
Intro: "问你想问的",
Qrcodebackcolor: "#FDF8F0",
Qrcodecolor: "#C45500",
Usernamecolor: "#000000",
Introcolor: "#000000",
Dotscale: "0.35",
BackgroundImage: conf.Upload.DefaultBackgroundImageURL,
Backgroundimagealpha: "0.8",
Qrcodepdpcolor: "#C45500",
Acfunlink: "none",
Bililink: "none",
Weibolink: "none",
Bloglink: "box.shiroko.one",
Xiaohongshulink: "none",
Language: i18n.Resolve(f.Language), // 注册时选择的界面语言;留空回退站点默认
}); err != nil {
switch {
// 防枚举:邮箱是否已注册,对外必须与"注册成功"不可区分。
//
// 注册接口原本会直接回"该邮箱已被注册",而找回密码接口(ForgotPasswordAction)
// 早就统一成"已发送"。两处口径不一致的结果是:任何人都能批量确认
// "某个邮箱是不是这个匿名提问箱的用户"—— 这是成员关系泄露,比一般站更敏感。
// 真实原因只写服务端日志(运营排查要看得到到底撞的是哪一类)。
case errors.Is(err, db.ErrDuplicateEmail):
logrus.WithContext(ctx.Request().Context()).
WithField("email", f.Email).
Warn("注册命中已存在的邮箱,按成功流程静默处理(防枚举)")
registerSucceeded(ctx)
return
case errors.Is(err, db.ErrDeactivatedEmail):
logrus.WithContext(ctx.Request().Context()).
WithField("email", f.Email).
Warn("注册命中已停用账号的邮箱,按成功流程静默处理(防枚举)")
registerSucceeded(ctx)
return
// 域名占用不遮蔽:域名是公开命名空间,/_/<domain> 本来就可探测,
// 遮蔽只会让用户以为自己注册成功、后续登不上又找不到原因。
case errors.Is(err, db.ErrDuplicateDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain")))
case errors.Is(err, db.ErrDeactivatedDomain):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain")))
case errors.Is(err, db.ErrBadCredential):
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errBadCredential")))
default:
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new user")
ctx.SetInternalError()
}
ctx.Success("auth/register")
return
}
registerSucceeded(ctx)
}
// registerSucceeded 是注册接口唯一的"成功"出口。
//
// 抽成函数是为了让"真的建成功了"和"因为邮箱已存在而没建"走同一条路径 ——
// 一旦两处分别写,响应体的措辞、跳转目标、flash 文案迟早会漂移出差异,
// 差异就是枚举信号。新增失败分支时若想遮蔽,直接调用本函数即可。
func registerSucceeded(ctx context.Context) {
// 成功提示跟随用户注册时选择的语言
lang := "zh-CN"
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetSuccessFlash(i18n.T(lang, "auth.register.success"))
ctx.Redirect("/login")
}