// 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 }