Files
tamabox/route/auth/login.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

75 lines
2.3 KiB
Go

// 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/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 Login(ctx context.Context) {
ctx.Success("auth/login")
}
func LoginAction(ctx context.Context, f form.Login, recaptcha recaptcha.RecaptchaV3) {
if ctx.HasError() {
ctx.Success("auth/login")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
uri := ctx.Request().Request.RequestURI // Keep the query when redirecting.
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(uri)
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect(uri)
return
}
user, err := db.Users.Authenticate(ctx.Request().Context(), f.Email, f.Password)
if err != nil {
if errors.Is(err, db.ErrBadCredential) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errBadCredential"))
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to authenticate user")
ctx.SetInternalErrorFlash()
}
ctx.Redirect(uri)
return
}
to := shared.SafeRedirectTarget(ctx.Query("to"))
if to == "" {
to = "/_/" + user.Domain
}
// 会话固定防护:登录认证成功后、写入用户标识之前,重新生成会话 ID,
// 使登录前种下的匿名会话失效,防止攻击者借固定会话 ID 劫持已认证会话。
if err := ctx.Session.RegenerateID(ctx.ResponseWriter(), ctx.Request().Request); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to regenerate session ID on login")
ctx.SetInternalErrorFlash()
ctx.Redirect(uri)
return
}
ctx.Session.Set("uid", user.ID)
ctx.Session.Set("pwd_ver", user.PasswordVersion)
ctx.Redirect(to)
}