Files
tamabox/internal/route/chat/chat_handler.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

437 lines
15 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.
package chat
import (
stdctx "context"
"fmt"
"mime/multipart"
"net/http"
"time"
"unicode/utf8"
"github.com/flamego/cache"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
MaxMessageImageSize = 5 * 1024 * 1024 // 5MB
)
// ConversationPage 私聊会话页(GET 入口)。审核拦截后的就地重渲染由 renderConversation 统一处理。
func ConversationPage(ctx context.Context) {
renderConversation(ctx, ctx.Param("domain"), ctx.Param("token"), "", nil)
}
// renderConversation 渲染私聊会话页,并可选地回填审核拦截提示与已输入内容。
//
// prefillContent 非空时回填到消息输入框:审核拦截(以及其它服务端校验失败)后保留用户已输入内容,
// 避免 Redirect 重渲染导致输入丢失。modErr 非空时作为错误提示展示(与提问链路 ctx.SetError
// 一致的就地重渲染,而非 flash + Redirect),提示通过 templates/base/alert 渲染。
// 函数内部在所有出口都会发起 Redirect 或 Success 渲染,调用方在调用后应直接 return。
func renderConversation(ctx context.Context, domain, token, prefillContent string, modErr error) {
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDeletedByOwner"))
ctx.Redirect("/_/" + domain)
return
}
// 纵深防御:私聊对话只能由其所属提问箱(domain 对应的真实属主)的页面访问。
// 否则攻击者可用其它 domain 构造访问,使下方拉黑判定用到错误的主体,绕过真实属主的拉黑。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者检查是否被对方(对话真实属主)拉黑
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status for asker")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedView"))
ctx.Redirect("/_/" + domain)
return
}
}
// 检查是否已拉黑对方(仅对被提问者有效,以对话真实属主为准)
var isBlocked bool
if isOwner {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status")
}
isBlocked = blocked
}
messages, err := db.Messages.GetByConversationID(ctx.Request().Context(), conv.ID, nil)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get messages")
ctx.SetInternalError()
return
}
// 获取所有消息的图片(被拉黑时不显示对方图片)
var messageImageMap map[uint][]*db.MessageImage
if !(isOwner && isBlocked) {
msgIDs := make([]uint, 0, len(messages))
for _, msg := range messages {
msgIDs = append(msgIDs, msg.ID)
}
messageImageMap, err = db.MessageImages.GetByMessageIDs(ctx.Request().Context(), msgIDs)
if err != nil {
logrus.WithError(err).Error("Failed to get message images")
messageImageMap = make(map[uint][]*db.MessageImage)
}
} else {
messageImageMap = make(map[uint][]*db.MessageImage)
}
ctx.Data["PageUser"] = pageUser
ctx.Data["Conversation"] = conv
ctx.Data["Messages"] = messages
ctx.Data["MessageImages"] = messageImageMap
ctx.Data["IsOwner"] = isOwner
ctx.Data["IsAsker"] = isAsker
ctx.Data["IsBlocked"] = isBlocked
// 单条消息字数上限,供发送 / 回复 textarea 的 maxlength 与后端校验同源
// (conf.Limits.ChatMessageLen,[limits] 配置段)
ctx.Data["ChatMessageLimit"] = conf.Limits.ChatMessageLen
canSend := !conv.IsClosed && !conv.IsDeletedByOwner &&
((isOwner && conv.MessageCount < conv.MaxMessages) || (isAsker && conv.MessageCount < conv.MaxMessages && !isBlocked))
ctx.Data["CanSend"] = canSend
// 回填审核拦截时已输入内容(仅当对话仍可发送,避免关闭/删除场景下回填到隐藏输入框)。
if canSend {
ctx.Data["Content"] = prefillContent
}
// 审核拦截等错误提示:就地通过 base/alert 渲染,不再走 flash + Redirect(否则内容丢失且无提示)。
if modErr != nil {
ctx.Data["HasError"] = true
ctx.Data["Error"] = modErr.Error()
}
ctx.Success("chat/conversation")
}
func SendMessage(ctx context.Context, cache cache.Cache) {
domain := ctx.Param("domain")
token := ctx.Param("token")
content := ctx.Request().FormValue("content")
// 处理图片上传
var imageFile multipart.File
var imageHeader *multipart.FileHeader
imageFile, imageHeader, err := ctx.Request().FormFile("image")
if err != nil && err != http.ErrMissingFile {
logrus.WithError(err).Error("Failed to read uploaded image")
}
if imageFile != nil {
defer imageFile.Close()
}
// 内容与图片至少有一个
if content == "" && imageFile == nil {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errEmpty"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 内容长度限制(上限取自 conf.Limits.ChatMessageLen,[limits] 配置段)
if utf8.RuneCountInString(content) > conf.Limits.ChatMessageLen {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errTooLong", conf.Limits.ChatMessageLen))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/")
return
}
if conv.IsDeletedByOwner {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errConvDeletedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 纵深防御(与 ConversationPage 同一口径):会话必须属于 URL 里这个箱子的真实属主。
//
// 少了这一步,domain 就只是一张"通行证":拿 A 箱的 domain 拼 B 箱的会话 token,
// 下面所有以 pageUser 为主体的判定都会落到 A 箱主身上 ——
// 被 B 箱主拉黑的提问者换个 domain 就能继续发消息,A 箱主的屏蔽词与日限额被套用到 B 的会话上。
// GET 页面早就有这条校验,POST 漏了。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
ctx.Redirect("/")
return
}
// 提问者发送时检查拉黑与每日限制
if isAsker {
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to check block")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockedSend"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 箱主全局屏蔽词(提问/私聊/投票共用一份词表):对方发给我的消息命中即拦截。
// 仅拦对方 → 箱主方向:我回复对方的内容不受自己设置的屏蔽词限制。
if content != "" {
if _, hit := shared.BlockWordHit(pageUser.BlockWords, content); hit {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockWords"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
if pageUser.DailyMessageLimit > 0 {
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
}
}
if conv.IsClosed || conv.MessageCount >= conv.MaxMessages {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
chatState := db.FeatureStateOf(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
if !chatState.Open {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 图片大小限制
if imageHeader != nil && imageHeader.Size > MaxMessageImageSize {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errImageTooLarge", MaxMessageImageSize/(1024*1024)))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 上传图片(如有)
var imageURL string
if imageFile != nil {
uploadedURL, err := storage.UploadPictureToS3(imageFile, imageHeader)
if err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "image.errTooLarge", imagecheck.MaxImageSide, imagecheck.MaxImageSide))
} else {
logrus.WithError(err).Error("Failed to upload image")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errUploadFailed"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageURL = uploadedURL
}
// 内容安全检测(仅文本)
// 统一守卫:与提问 / 投票共用同一份档位判定,箱主的「审核严格度」决定放行与否。
if content != "" {
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), content); err != nil {
// 审核拦截:就地重渲染会话页,回填已输入内容并展示拦截原因。
// 不复用 SetErrorFlash + Redirect:那会导致输入框内容在刷新后丢失(用户已确认的无提示+内容消失问题)。
renderConversation(ctx, domain, token, content, err)
return
}
}
// 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。
// 并发场景(对话双方同时发送)下,仅当 message_count < max 时才 +1 成功。
ok, err := db.Conversations.IncrementMessageCountIfBelow(ctx.Request().Context(), conv.ID, conv.MaxMessages)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 创建消息
msg, err := db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: content,
})
if err != nil {
// 计数已递增但消息落库失败:回滚计数,避免占用配额
if rerr := db.Conversations.DecrementMessageCount(ctx.Request().Context(), conv.ID); rerr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(rerr).Error("Failed to rollback message count")
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create message")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 保存图片记录(如有)
if imageURL != "" {
if _, err := db.MessageImages.Create(ctx.Request().Context(), db.CreateMessageImageOptions{
MessageID: msg.ID,
URL: imageURL,
FileName: imageHeader.Filename,
FileSize: imageHeader.Size,
}); err != nil {
logrus.WithError(err).Error("Failed to save message image")
}
}
// 达到上限时关闭对话(幂等,多实例同时执行也安全)
if err := db.Conversations.MaybeClose(ctx.Request().Context(), conv.ID, conv.MaxMessages); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to maybe close conversation")
}
senderName := ctx.User.Name
shared.SafeGo(func() {
var recipientEmail string
if isOwner {
asker, err := db.Users.GetByID(stdctx.Background(), conv.AskerID)
if err == nil {
recipientEmail = asker.Email
}
} else {
owner, err := db.Users.GetByID(stdctx.Background(), conv.OwnerID)
if err == nil {
recipientEmail = owner.Email
}
}
if recipientEmail == "" {
return
}
// 邮件节流:同一收件人 5 分钟内最多一封通知,防止刷屏导致邮件轰炸。
notifyKey := "chat-notify:" + recipientEmail
if _, err := cache.Get(stdctx.Background(), notifyKey); err == nil {
return
}
if err := mail.SendNewMessageMail(recipientEmail, senderName, pageUser.Domain, token, content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new message mail")
return
}
_ = cache.Set(stdctx.Background(), notifyKey, time.Now(), 5*time.Minute)
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.sent"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
}
func DeleteConversation(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
if err := db.Conversations.MarkDeletedByOwner(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark conversation deleted")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.deleted"))
ctx.Redirect("/user/questions")
}
// ToggleBlockUser 拉黑/取消屏蔽
func ToggleBlockUser(ctx context.Context) {
token := ctx.Param("token")
conv, err := db.Conversations.GetByToken(ctx.Request().Context(), token)
if err != nil {
ctx.Redirect("/user/questions")
return
}
if !ctx.IsLogged || ctx.User.ID != conv.OwnerID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errNoPermission"))
ctx.Redirect("/user/questions")
return
}
blocked, _ := db.Users.IsUserBlocked(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
if blocked {
err = db.Users.UnblockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
} else {
err = db.Users.BlockUser(ctx.Request().Context(), conv.OwnerID, conv.AskerID)
}
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle block")
ctx.SetInternalErrorFlash()
} else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "chat.flash.operated"))
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", ctx.Param("domain"), token))
}