fix anwser token and database issus

This commit is contained in:
tamakyi
2026-09-15 11:06:09 +08:00
parent e683e798aa
commit adcc42cc8c
33 changed files with 1011 additions and 802 deletions
+33 -49
View File
@@ -49,13 +49,11 @@ import (
// 用于防止把路由参数直接拼进 embed FS 路径时发生路径穿越(如 "../robots.txt")。
var indexNowKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.txt$`)
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
//
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
// CI 失败,避免修复悄悄倒退。
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的键一致
// (见 route/auth/login.go 的 ctx.Session.Set("uid", ...))。flamego/csrf 默认用
// "userID",不对齐则 token id 恒为 "0",匿名访客从公开页取到的 token 对所有已登录
// 用户均有效,CSRF 退化为全站公开常量。
// 本常量被 csrf_binding_test.go 引用作为单一事实来源,误改会让回归测试失败。
const CSRFUserSessionKey = "uid"
// securityHeaders adds security-related HTTP headers to every response.
@@ -80,9 +78,8 @@ func New() *flamego.Flame {
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
// 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行,
// 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
// 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。
ab := autoban.New(autoban.Options{
Enabled: conf.Security.EnableAutoBan,
TrackNotFound: conf.Security.BanTrackNotFound,
@@ -98,10 +95,6 @@ func New() *flamego.Flame {
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
},
})
// 需要输出详细日志时配置如下
// if conf.App.Production {
// flamego.SetEnv(flamego.EnvTypeProd)
// }
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
if err != nil {
@@ -134,11 +127,9 @@ func New() *flamego.Flame {
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
// 显式声明 cookie 安全属性,不依赖库默认值:
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
// 显式声明 cookie 安全属性:HTTPOnly 防 XSS 窃取会话;SameSite=Lax 阻断跨站表单
// POST;Secure 仅在 https 对外服务时下发,避免 http 本地开发下 cookie 不生效
// (缺失 Secure 时,HSTS 尚未落地的首次访问可被 MITM 诱导明文请求窃取会话)。
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
@@ -227,11 +218,11 @@ func New() *flamego.Flame {
_, _ = io.Copy(c.ResponseWriter(), fs)
})
// IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex / Seznam
// 等支持 IndexNow 的搜索引擎校验站点所有权(提交 URL 时的 keyLocation 指向此处)。
// ⚠️ flamego 的 Static 中间件 Prefix 为 "/static",不会接管本路径,必须在此显式注册,
// 否则请求会落到 f.NotFound 被 302 重定向到首页,搜索引擎校验必然失败。
// 文件名用 indexNowKeyPattern 严格白名单校验,避免路径穿越读取 embed FS 内其它文件。
// IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex 等
// 搜索引擎校验站点所有权。
// ⚠️ flamego Static 的 Prefix 是 "/static",不会接管本路径,必须显式注册,
// 否则会落到 f.NotFound 被 302 到首页,校验必然失败。
// 文件名经 indexNowKeyPattern 白名单校验,避免路径穿越读 embed FS 内其它文件。
f.Get("/index-now/{key}", func(c context.Context) {
key := c.Param("key")
if !indexNowKeyPattern.MatchString(key) {
@@ -282,7 +273,7 @@ func New() *flamego.Flame {
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
}, question.Questioner)
// 新增:私聊路由(需要登录,使用 Token 标识对话)
// 私聊路由(需登录,Token 标识对话)
f.Group("/chat/{token}", func() {
f.Get("", chat.ConversationPage)
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
@@ -300,16 +291,14 @@ func New() *flamego.Flame {
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
})
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
// 公开页语言切换:语言偏好写入 session 后跳回原页面。此前写独立 lang cookie
// 会被 session 中间件的 Set-Cookie 整体覆盖而失效,故托管在 session 中。
f.Get("/i18n/{lang}", func(c context.Context) {
lang := i18n.Resolve(c.Param("lang"))
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
c.Session.Set("lang", lang)
to := c.Query("to")
// 仅允许站内相对路径,防开放重定向:
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
// 仅允许站内相对路径,防开放重定向:拒绝空、非 "/" 开头、协议相对路径 "//",
// 以及含反斜杠 / 控制字符的变体。
if to == "" || !strings.HasPrefix(to, "/") ||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
to = "/"
@@ -360,13 +349,10 @@ func New() *flamego.Flame {
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
})
})
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
// ?tab= 参数回跳到来源面板。
// 防骚扰与全局屏蔽词拆为两个独立入口,避免同表单一次写两列时相互清空。
// 保存后经 ?tab= 参数回跳到来源面板。
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
// 内容审核严格度:与屏蔽词一样在提问/私聊/投票三个面板共用一份,
// 保存后经 ?tab= 参数回跳到来源面板。
f.Post("/moderation-strictness/update", form.Bind(form.UpdateModerationStrictness{}), user.UpdateModerationStrictness)
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
@@ -418,12 +404,11 @@ func New() *flamego.Flame {
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
// ⚠️ 必须与 login.go 的 ctx.Session.Set("uid", ...) 一致。flamego/csrf 默认
// SessionKey 为 "userID",不显式指定则 s.Get("userID") 恒为 nil、token 内 id
// 恒为 "0";校验是无状态 HMAC 重算(不与 session 内 token 比对),匿名访客从
// 公开页取到的 token 会对所有已登录用户有效,CSRF 退化为全站公开常量。
// 回归测试见 route/csrf_binding_test.go。
SessionKey: CSRFUserSessionKey,
}),
template.Templater(template.Options{
@@ -433,13 +418,12 @@ func New() *flamego.Flame {
context.Contexter(),
)
f.NotFound(func(ctx flamego.Context) {
// 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
// 因此这里在 Redirect 之前记录,确保状态为 404。
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
// ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行,
// 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。flamego 的 NotFound 会先
// 写 404 状态再 Redirect,故须在 Redirect 前记录。
// 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合),
// 否则计数落在另一个键上,封禁永不生效。
// ⚠️ 计数受 ban_track_notfound 控制,默认关闭;Record 内部会忽略未开启的
// KindNotFound。
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
}