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

600 lines
28 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 route
import (
stdctx "context"
"encoding/gob"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/flamego/cache"
cacheRedis "github.com/flamego/cache/redis"
"github.com/flamego/csrf"
"github.com/flamego/flamego"
"github.com/flamego/recaptcha"
"github.com/flamego/session"
"github.com/flamego/session/mysql"
sessionRedis "github.com/flamego/session/redis"
"github.com/flamego/template"
"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/mail"
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
"github.com/tamakyi/TamaBox/internal/security/autoban"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
"github.com/tamakyi/TamaBox/internal/shared"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/route"
"github.com/tamakyi/TamaBox/route/auth"
"github.com/tamakyi/TamaBox/route/pixel"
"github.com/tamakyi/TamaBox/route/question"
"github.com/tamakyi/TamaBox/route/service"
"github.com/tamakyi/TamaBox/route/user"
"github.com/tamakyi/TamaBox/route/vote"
"github.com/tamakyi/TamaBox/static"
"github.com/tamakyi/TamaBox/templates"
)
// indexNowKeyPattern 限制 IndexNow 密钥文件名的合法形态:32 位小写十六进制 + ".txt"。
// 用于防止把路由参数直接拼进 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 引用作为单一事实来源,误改会让回归测试失败。
const CSRFUserSessionKey = "uid"
// securityHeaders adds security-related HTTP headers to every response.
func securityHeaders() flamego.Handler {
return func(ctx flamego.Context) {
w := ctx.ResponseWriter()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
// X-XSS-Protection 已是废弃的响应头:Chrome 移除、新 Firefox 不实现,
// 而旧版 IE/Edge 的反射型 XSS 过滤器本身有信息泄露缺陷(CVE-2018-10016 一类)。
// 现代建议是显式关掉,把防护交给 CSP 与输出编码。
w.Header().Set("X-XSS-Protection", "0")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// 站点不需要这些能力,显式全禁,避免被嵌入页 / 第三方脚本索要权限。
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
if strings.HasPrefix(conf.App.ExternalURL, "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
// CSP:off 时完全不出现;report 只下发 Report-Only(页面行为不变);
// enforce 才真正拦截。见 csp.go 与 [security] csp 的注释。
switch cspMode() {
case "enforce":
w.Header().Set("Content-Security-Policy", cspPolicy())
case "report":
w.Header().Set("Content-Security-Policy-Report-Only", cspPolicy())
}
// 敏感页面禁止缓存:
// /user/** 箱主后台(资料、会话、私信全在里面)
// /_/** 箱主页与单题页(单题页带 t= token,等于一把钥匙)
// 不管的话,共享设备上「退出登录 → 后退键」还能翻出完整后台(bfcache / 磁盘缓存);
// 将来若给 /user 配了 CDN 的 Cache Everything,还会把个人数据跨用户回放。
// 静态资源不在此列(/static 前缀不匹配)。
if p := ctx.Request().URL.Path; isNoStorePath(p) {
w.Header().Set("Cache-Control", "no-store, private")
w.Header().Set("Pragma", "no-cache")
}
}
}
// isNoStorePath 判断该路径是否属于「不允许任何缓存」的敏感面。
func isNoStorePath(p string) bool {
// 分享卡例外:它是给社交平台抓取的图片,本来就以公开分享为目的,而且渲染成本高
// (要画图 + 上传/落盘)。给它 no-store 会让每次 OG 预览都回源重画一张。
if strings.HasSuffix(p, "/share-card") {
return false
}
return p == "/user" || strings.HasPrefix(p, "/user/") ||
p == "/_" || strings.HasPrefix(p, "/_/")
}
// newBanner 按 conf.Security 装配自动封禁器。
//
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
// (HoneypotEnabled / HoneypotThreshold / HoneypotDuration)曾经在这里漏传,
// 而 autoban.RecordHoneypot 第一行就是 `if !b.honeypotEnabled { return false }`
// —— 结果是蜜罐命中永不封禁、落库的 banned 恒为 false、IsBanned 永不为真。
// 更糟的是当时的测试都自建 Banner 并显式传了这些字段,所以全绿。
//
// 集中到一处后,banner_config_test.go 只调这个函数就能断言「配置真的传进去了」,
// 下次再漏传会直接测试失败。
func newBanner() *autoban.Banner {
return autoban.New(autoban.Options{
Enabled: conf.Security.EnableAutoBan,
TrackNotFound: conf.Security.BanTrackNotFound,
ThresholdSensitive: conf.Security.BanThresholdSensitive,
Threshold404: conf.Security.BanThreshold404,
Window: time.Duration(conf.Security.BanWindowSeconds) * time.Second,
Duration: time.Duration(conf.Security.BanDurationMinutes) * time.Minute,
AlertEmail: conf.Security.SecurityAlertEmail,
// 告警发送前先离线识别来源 IP 的网络归属(CDN/云厂商/WAF,零外部请求),
// 识别失败不影响告警发送(ipinfo.Lookup 内部已折叠错误)。
SendAlert: func(ip string, paths []string, durationMinutes int) error {
provider, proxied := ipinfo.Lookup(ip)
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
},
// 蜜罐独立通道:开关跟随 enable_honeypot,不受 enable_auto_ban 约束。
// 蜜罐命中意味着对方已经在往假后台里提交账号密码,证据强度远高于
// 「访问了一个可疑路径」,因此单独给阈值与时长,并默认封得比常规久。
HoneypotEnabled: conf.Security.EnableHoneypot,
HoneypotThreshold: conf.Security.HoneypotThreshold,
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
// 建封日志:封禁表是纯内存、外部完全看不见,而线上出现过
// 「邮件说封了、实际还能访问」—— 当时没有任何记录能说明封的是哪个键、
// 到什么时候解除,排查只能靠猜。键与 IsBanned 的判断口径完全一致
// (shared.LimitKey,IPv6 折成 /64),日志与判定不会各说一套。
OnBan: func(key string, duration time.Duration, reason string) {
reasonText := "敏感路径探测"
if reason == autoban.ReasonHoneypot {
reasonText = "蜜罐提交凭据"
}
expiresAt := time.Now().Add(duration)
logrus.WithFields(logrus.Fields{
"ban_key": key,
"reason": reasonText,
"duration": duration.String(),
"expires_at": expiresAt.Format("2006-01-02 15:04:05"),
}).Warn("已自动封禁来源(内存态,重启进程即解除;键为聚合口径,IPv6 为 /64)")
// 落库:内存态封禁重启即失效,事后没人能回答「封了哪个键、因为什么、封多久」。
// 异步 + 独立超时 + 失败只告警 —— 取证记录不能反过来拖慢或影响封禁本身。
// BanRecords 为 nil 表示未经 db.Init(单测里自建 Banner 就是这种情形)。
if db.BanRecords == nil {
return
}
record := &db.BanRecord{
BanKey: key,
Reason: reasonText,
DurationMinutes: int(duration.Minutes()),
ExpiresAt: expiresAt,
}
go func() {
ctx, cancel := stdctx.WithTimeout(stdctx.Background(), 3*time.Second)
defer cancel()
if err := db.BanRecords.Create(ctx, record); err != nil {
logrus.WithError(err).WithField("ban_key", record.BanKey).Warn("封禁记录落库失败")
}
}()
},
})
}
func New() *flamego.Flame {
f := flamego.Classic()
if conf.App.Production {
flamego.SetEnv(flamego.EnvTypeProd)
}
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
ab := newBanner()
// 封禁响应图:启动时读一次,之后每个被封请求只读内存。
// 留空 / 读不到 / 不是图片 → 回退纯文字 403(见 loadBanImage),
// 绝不因为一张图把站点拦在启动阶段。
loadBanImage(conf.Security.BanImagePath)
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
hp := honeypot.New(honeypot.Options{
Enabled: conf.Security.EnableHoneypot,
ExtraPaths: honeypot.SplitPaths(conf.Security.HoneypotPaths),
})
// 凭据外置:账号密码绝不入库,落到 0600 权限的按天 JSONL 文件
// (见 honeypot.CredRecord 的注释)。目录不可写时降级为「不保存凭据」,
// 绝不让取证功能把站点拖挂。
credStore, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
if err != nil {
logrus.WithError(err).WithField("dir", conf.Security.HoneypotCredDir).
Warn("蜜罐凭据目录不可用,捕获到的凭据将不会被保存(其余功能不受影响)")
credStore = nil
}
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
// 收件人复用 security_alert_email,不新增配置项。
hph.sendAlert = func(a mail.HoneypotAlert) error {
return mail.SendHoneypotAlertMail(a)
}
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
if err != nil {
logrus.WithError(err).Fatal("Failed to embed templates file system")
}
gob.Register(time.Time{})
gob.Register(context.Flash{})
gob.Register(captcha.StoredAnswer{})
var sessionStorage interface{}
initer := session.MemoryIniter()
if conf.Database.DSN != "" {
initer = mysql.Initer()
sessionStorage = mysql.Config{
DSN: conf.Database.DSN,
Lifetime: 7 * 24 * time.Hour,
}
}
if conf.Redis.Addr != "" {
initer = sessionRedis.Initer()
sessionStorage = sessionRedis.Config{
Options: &sessionRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 1,
},
Lifetime: 7 * 24 * time.Hour,
}
}
sessioner := session.Sessioner(session.Options{
Initer: initer,
Config: sessionStorage,
// 显式声明 cookie 安全属性:HTTPOnly 防 XSS 窃取会话;SameSite=Lax 阻断跨站表单
// POST;Secure 仅在 https 对外服务时下发,避免 http 本地开发下 cookie 不生效
// (缺失 Secure 时,HSTS 尚未落地的首次访问可被 MITM 诱导明文请求窃取会话)。
Cookie: session.CookieOptions{
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
},
})
// 安全响应头必须注册在静态资源之前:flamego.Static 命中后会终结请求链,
// 放在它后面等于静态资源拿不到 nosniff / HSTS。
f.Use(securityHeaders())
f.Use(flamego.Static(flamego.StaticOptions{
FileSystem: http.FS(static.FS),
Prefix: "/static",
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
CacheControl: func() string {
return "public, max-age=86400"
},
}))
f.Use(ipBlacklistMiddleware())
f.Use(autobanMiddleware(ab, hp))
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
f.Use(requestBodyLimitMiddleware())
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
f.Group("", func() {
f.Get("/", route.Home)
f.Get("/pixel", reqUserSignIn, pixel.Index)
f.Get("/sponsor", route.Sponsor)
f.Get("/change-logs", route.ChangeLogs)
f.Get("/about-me", route.AboutMe)
// 社区规则:公开页,处置时的引用依据。
f.Get("/rules", route.Rules)
// 全站数据页:公开只读,纯聚合数字,不含任何单条内容或来源信息。
// 数据按自然日缓存(每天算一次),所以这里不需要额外限流。
f.Get("/stats", route.Stats)
f.Get("/robots.txt", func(c context.Context) {
fs, err := static.FS.Open("robots.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/favicon.ico", func(c context.Context) {
fs, _ := static.FS.Open("favicon.ico")
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "image/x-icon")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/sitemap.xml", func(c context.Context) {
fs, err := static.FS.Open("sitemap.xml")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "application/xml")
c.ResponseWriter().Header().Set("Content-Type", "text/xml; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms.txt", func(c context.Context) {
fs, err := static.FS.Open("llms.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Get("/llms-full.txt", func(c context.Context) {
fs, err := static.FS.Open("llms-full.txt")
if err != nil {
c.ResponseWriter().WriteHeader(404)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), 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) {
c.ResponseWriter().WriteHeader(http.StatusNotFound)
return
}
fs, err := static.FS.Open("index-now/" + key)
if err != nil {
c.ResponseWriter().WriteHeader(http.StatusNotFound)
return
}
defer func() { _ = fs.Close() }()
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.Copy(c.ResponseWriter(), fs)
})
f.Group("", func() {
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), rateLimitMiddleware(10, 1*time.Minute), auth.RegisterAction)
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), rateLimitMiddleware(20, 1*time.Minute), auth.LoginAction)
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), rateLimitMiddleware(5, 5*time.Minute), auth.ForgotPasswordAction)
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
}, reqUserSignOut)
// 页面密码验证路由(独立于密码保护中间件);限流防爆破
f.Combo("/_/{domain}/password").Get(question.PasswordPage).Post(rateLimitMiddleware(10, 1*time.Minute), question.VerifyPagePassword)
// 独立的分享图路由(不经过 Pager 中间件,直接返回图片)
// 注意:仍挂载 question.Pager,页面设置了密码时必须先通过密码验证,
// 否则分享图会绕过页面密码保护(与单题分享图行为一致)。
f.Group("/_/{domain}", func() {
f.Get("/share-card", question.ShareCard)
}, question.Pager)
f.Group("/_/{domain}", func() {
f.Combo("").Get(question.List).Post(form.Bind(form.NewQuestion{}), rateLimitMiddleware(30, 1*time.Minute), question.New)
f.Group("/{questionID}", func() {
f.Get("", question.Item)
f.Get("/share-card", question.QuestionShareCard)
f.Post("/delete", question.Delete)
f.Post("/set-private", question.SetPrivate)
f.Post("/set-public", question.SetPublic)
f.Post("/pin", question.TogglePin)
f.Post("/read", question.SetRead)
f.Post("/unread", question.SetUnread)
// 「下一条未读」= 标记本条已读 + 跳到最早的一条未读(改变状态,故为 POST)
f.Post("/next-unread", question.NextUnread)
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
// 举报入口:公开、不要求登录(主要目击者是匿名访客),限流防刷。
// 举报只写入 content_reports,不改变任何可见性;处置由箱主或 CLI 完成。
f.Post("/report", form.Bind(form.ReportQuestion{}), rateLimitMiddleware(10, 1*time.Minute), question.Report)
f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
}, question.Questioner)
// 私聊路由(需登录,Token 标识对话)
f.Group("/chat/{token}", func() {
f.Get("", chat.ConversationPage)
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
f.Post("/delete", chat.DeleteConversation)
f.Post("/block", chat.ToggleBlockUser)
}, reqUserSignIn)
}, question.Pager) // Pager 中间件已内置页面密码检查
f.Get("/explore", user.Explore)
// 账号停用成功页(公开):停用后会话已清空,不能再要求登录,
// 故为独立公开页,让用户明确看到「已停用」而非被丢回登录页。
f.Get("/deactivated", user.DeactivatedProfile)
// 处置停用页(L4):必须注册在公开区,被处置箱主自己也只能看到这一页。
f.Get("/restricted", user.RestrictedProfile)
// 匿名投票参与端(公开):token 链接入口
f.Group("/vote/{token}", func() {
f.Get("", vote.Page)
// 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
})
// 公开页语言切换:语言偏好写入 session 后跳回原页面。此前写独立 lang cookie
// 会被 session 中间件的 Set-Cookie 整体覆盖而失效,故托管在 session 中。
f.Get("/i18n/{lang}", func(c context.Context) {
lang := i18n.Resolve(c.Param("lang"))
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 = "/"
}
c.Redirect(to)
})
// 算数验证码 API(仅在 captcha_type = "math" 时生效,但路由本身不依赖中间件)
// 加限流:防止匿名脚本无限生成/轮询爆破(答案本身对客户端可见,限流是主要节流手段)。
f.Get("/api/captcha/generate", rateLimitMiddleware(20, 1*time.Minute), captcha.GenerateHandler)
f.Post("/api/captcha/verify", rateLimitMiddleware(20, 1*time.Minute), captcha.VerifyHandler)
f.Group("/user", func() {
f.Get("/questions", user.QuestionList)
// 匿名投票管理(箱主侧)
f.Group("/votes", func() {
f.Get("", user.VoteList)
f.Get("/new", user.VoteNew)
f.Post("", rateLimitMiddleware(10, 1*time.Minute), user.VoteCreate)
f.Get("/{voteID}/edit", user.VoteEdit)
f.Post("/{voteID}", rateLimitMiddleware(10, 1*time.Minute), user.VoteUpdate)
f.Post("/{voteID}/publish", user.VotePublish)
f.Post("/{voteID}/close", user.VoteClose)
f.Post("/{voteID}/delete", user.VoteDelete)
f.Get("/{voteID}/results", user.VoteResults)
f.Get("/{voteID}/preview", user.VotePreview)
// 导出为只读操作(幂等、无副作用),用 GET 链接下载,避免表单提交
f.Get("/{voteID}/export", user.VoteExportCSV)
})
f.Group("/profile", func() {
f.Get("", user.Profile)
f.Post("/update", form.Bind(form.UpdateProfile{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateProfile)
f.Post("/export", user.ExportProfile)
f.Post("/export-json", user.ExportProfileJSON)
f.Post("/theme", form.Bind(form.UpdateProfileTheme{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateTheme)
f.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
f.Post("/update-ask-password", form.Bind(form.UpdateAskPassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateAskPassword)
f.Post("/update-chat-settings", user.UpdateChatSettings)
// 功能管理(提问 / 私聊 / 投票 的开关、定时开关与定量收集):
// 不单独开页面,作为账号设置页的三个面板,保存后回跳到对应面板。
f.Group("/features", func() {
f.Post("/update", rateLimitMiddleware(20, 1*time.Minute), user.FeaturesUpdate)
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
})
})
// 防骚扰与全局屏蔽词拆为两个独立入口,避免同表单一次写两列时相互清空。
// 保存后经 ?tab= 参数回跳到来源面板。
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
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 风险(第三方图片/链接可触发退出登录)
f.Post("/logout", auth.Logout)
}, reqUserSignIn)
f.Group("/api/v1", func() {
f.Group("/user", func() {
f.Get("", reqUserSignIn, user.ProfileAPI)
f.Group("/{domain}", func() {
f.Group("/questions", func() {
f.Get("", rateLimitMiddleware(60, 1*time.Minute), question.ListAPI)
})
})
})
// 像素追踪代理:仅暴露只读 GET(f.Any 会放行写方法,曾有 CSRF 豁免 + 写方法放行的风险),
// 并加限流防止被用作高频打点通道。
f.Get("/pixel/{**}", reqUserSignIn, rateLimitMiddleware(30, 1*time.Minute), pixel.Proxy)
// 服务反代仅暴露只读 GET:本网关是把已登录用户的请求转发到配置好的内部服务,
// 不应允许任意写方法,避免被用作对内网后端的越权操作入口。
f.Get("/service/{**}", reqUserSignIn, service.Proxy)
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
}, context.APIEndpoint)
},
cache.Cacher(cache.Options{
Initer: cacheRedis.Initer(),
Config: cacheRedis.Config{
Options: &cacheRedis.Options{
Addr: conf.Redis.Addr,
Password: conf.Redis.Password,
DB: 0,
},
},
}),
recaptcha.V3(
recaptcha.Options{
Secret: conf.Recaptcha.ServerKey,
VerifyURL: func() recaptcha.VerifyURL {
if conf.Recaptcha.TurnstileStyle {
return "https://challenges.cloudflare.com/turnstile/v0/siteverify"
}
return recaptcha.VerifyURLGlobal
}(),
},
),
sessioner,
csrf.Csrfer(csrf.Options{
Secret: conf.Server.XSRFKey,
Header: "X-CSRF-Token",
// ⚠️ 必须与 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{
FileSystem: templateFS,
FuncMaps: templatepkg.FuncMap(),
}),
context.Contexter(),
)
f.NotFound(func(ctx flamego.Context) {
// ── 蜜罐分流(优先)───────────────────────────────────────────────
// 命中诱饵路径(/wp-admin、/.ssh、/.env 等后台探测特征)时,返回一个
// 仿 WordPress 的假登录页,并把攻击者提交的凭据记录入库。
//
// 为什么挂在 NotFound 而不是全局中间件:NotFound 只在「路由表完全落空」
// 时触发,因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等
// 真实业务路由天然零交集,不会劫持正常用户;且它位于全局 f.Use 链之后,
// requestBodyLimitMiddleware 已对 body 施加上限,解析 POST body 安全。
// 组级中间件(含 CSRF)只挂在具体路由上,不作用于 NotFound,故表单可解析。
//
// 短路:flamego 的 context.run() 在 ResponseWriter().Written() 为真时即
// return,故此处写完响应且不调用 ctx.Next() 即可安全终止整条链路。
if hp != nil && hp.Match(ctx.Request().URL.Path) {
hph.Serve(ctx)
return
}
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。
// ⚠️ 更正:flamego 的 NotFound 处理器并不会自动写 404 状态码 ——
// ResponseWriter.Write() 在未显式 WriteHeader 时写的是 200,
// ctx.Redirect("/") 写的是 302 + Location: /。这里保持原有 302→/ 行为
// (对扫描器而言,302 到首页同样表示「此路径不存在」)。
// 键必须与 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)
}
ctx.Redirect("/")
})
return f
}