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 ./... 全通过。
This commit is contained in:
@@ -191,6 +191,17 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
// cspMode 归一化 [security] csp 的取值。默认 enforce。
|
||||
//
|
||||
// 为什么默认 enforce 而不是 report:本项没有配置 report-uri 时,report-only 只是
|
||||
// 「发个头、什么都不拦」,等于没装 —— 装个不生效的开关反而给人「已防护」的错觉。
|
||||
// 已用真浏览器巡检 20 个页面(匿名 11 + 登录 6 + 单题/投票/分享图)零违规,见
|
||||
// outputs/sec-audit-2026-10-25/csp_sweep.py。
|
||||
//
|
||||
// 两个方向的安全性都保留了:写错值(如 "enforce " 以外的拼写错误)退到 report,
|
||||
// 宁可少拦也不让整站样式与交互崩掉;真要临时关掉写 off。
|
||||
func cspMode() string {
|
||||
switch strings.ToLower(strings.TrimSpace(conf.Security.CSP)) {
|
||||
case "off", "none", "false", "0":
|
||||
return "off"
|
||||
case "report", "report-only", "reportonly":
|
||||
return "report"
|
||||
case "enforce", "on", "true", "1", "":
|
||||
return "enforce"
|
||||
default:
|
||||
return "report"
|
||||
}
|
||||
}
|
||||
|
||||
// cspOrigin 把配置里的「域名」或「完整 URL」统一成 scheme://host 形态。
|
||||
// 配置项经常是两种写法混用(如 image_bucket_cdn_host 常写成 host/path),
|
||||
// 直接拼进 CSP 会变成非法源而被浏览器整条忽略。
|
||||
func cspOrigin(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// cspPolicy 构造 Content-Security-Policy 头。
|
||||
//
|
||||
// 目标是补一道纵深防御,而不是把页面卡死:XSS 过滤器是一套手写白名单,
|
||||
// 任何一次改动都可能开口子;CSP 让「即使注入了 <script src="//evil">」也执行不了。
|
||||
//
|
||||
// 白名单口径:
|
||||
// - script 收得很紧(本站 + 内联 + eval + 公共 CDN + 验证码域 + 图片 CDN);
|
||||
// 'unsafe-eval' 不能省 —— Alpine 3 用 new Function 编译 x-data 表达式,去掉整站交互失效。
|
||||
// - 图片 / 样式 / 字体 / 连接放宽到 https::箱主上传图、社交链接头像、主题背景都可能
|
||||
// 落在任意 CDN 上,收紧到具体域名只会换来一堆「图裂了」且很难自查。
|
||||
// - object-src / base-uri / form-action / frame-ancestors 一并锁死。
|
||||
func cspPolicy() string {
|
||||
self := "'self'"
|
||||
|
||||
scriptSrc := []string{
|
||||
self, "'unsafe-inline'", "'unsafe-eval'",
|
||||
// 公共 CDN:UIkit / Alpine / dayjs / vConsole / waline(见 templates/base/header.html)
|
||||
"https://cdnjs.snrat.com",
|
||||
// umami 站点统计:header.html 在 window load 后动态注入 <script>,
|
||||
// 静态 grep 模板里的 <script src> 抓不到它 —— 这是 enforce 实测时踩出来的
|
||||
// 第一个漏项,浏览器报 "Loading the script ... violates ... script-src"。
|
||||
"https://mami.shiroko.one",
|
||||
// Cloudflare Turnstile
|
||||
"https://challenges.cloudflare.com",
|
||||
}
|
||||
frameSrc := []string{"https://challenges.cloudflare.com"}
|
||||
// Sentry 浏览器 SDK:templates/base/header.html 里是 `{{ if ne SentryDSN "" }}` 包着的,
|
||||
// 不配 DSN 就完全不存在。所以这里也跟着配置条件加 —— 否则要么开着 enforce 时配了 DSN
|
||||
// 才发现错误上报被拦(静默失效),要么白名单里常年挂一个用不到的第三方源。
|
||||
// 上报走 connect-src,那边的 https: 已经覆盖 *.ingest.sentry.io。
|
||||
if strings.TrimSpace(conf.App.SentryDSN) != "" {
|
||||
scriptSrc = append(scriptSrc, "https://browser.sentry-cdn.com")
|
||||
}
|
||||
// 图片 CDN 也托管了脚本(部分库直接放在 R2 上),所以进 script-src;
|
||||
// 存储 API 端点(ImageEndpoint / AliyunEndpoint)不进 —— 浏览器从不直接加载它们。
|
||||
for _, raw := range []string{
|
||||
conf.Upload.ImageBucketCDNHost,
|
||||
conf.Upload.AliyunBucketCDNHost,
|
||||
} {
|
||||
if origin := cspOrigin(raw); origin != "" {
|
||||
scriptSrc = append(scriptSrc, origin)
|
||||
}
|
||||
}
|
||||
// 验证码域既是脚本源也是 iframe 源(Turnstile / reCAPTCHA 都是 iframe 形态)
|
||||
if origin := cspOrigin(conf.Recaptcha.Domain); origin != "" {
|
||||
scriptSrc = append(scriptSrc, origin)
|
||||
frameSrc = append(frameSrc, origin)
|
||||
}
|
||||
|
||||
directives := []string{
|
||||
"default-src " + self,
|
||||
"script-src " + strings.Join(dedupeStrings(scriptSrc), " "),
|
||||
"style-src " + self + " 'unsafe-inline' https:",
|
||||
"img-src " + self + " data: blob: https:",
|
||||
"font-src " + self + " data: https:",
|
||||
"media-src " + self + " https:",
|
||||
"frame-src " + strings.Join(dedupeStrings(frameSrc), " "),
|
||||
"connect-src " + self + " https:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action " + self,
|
||||
"frame-ancestors 'none'",
|
||||
}
|
||||
if uri := strings.TrimSpace(conf.Security.CSPReportURI); uri != "" {
|
||||
directives = append(directives, "report-uri "+uri)
|
||||
}
|
||||
return strings.Join(directives, "; ")
|
||||
}
|
||||
|
||||
// dedupeStrings 去重并保持顺序(白名单重复项无害,但排掉便于比对与测试)。
|
||||
func dedupeStrings(in []string) []string {
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
// withSecurityConf 临时改 conf.Security 并在测试结束后还原,避免污染同包其它用例。
|
||||
func withSecurityConf(t *testing.T, csp, reportURI string) {
|
||||
t.Helper()
|
||||
oldCSP, oldURI := conf.Security.CSP, conf.Security.CSPReportURI
|
||||
conf.Security.CSP = csp
|
||||
conf.Security.CSPReportURI = reportURI
|
||||
t.Cleanup(func() {
|
||||
conf.Security.CSP = oldCSP
|
||||
conf.Security.CSPReportURI = oldURI
|
||||
})
|
||||
}
|
||||
|
||||
func TestCSPMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", "enforce"}, // 未配置:默认拦截(report-only 没有上报地址等于没装)
|
||||
{"report", "report"},
|
||||
{"REPORT", "report"},
|
||||
{"report-only", "report"},
|
||||
{"off", "off"},
|
||||
{"none", "off"},
|
||||
{"enforce", "enforce"},
|
||||
{" true ", "enforce"},
|
||||
{"enfroce", "report"}, // 拼错退化为不拦截
|
||||
}
|
||||
for _, tc := range cases {
|
||||
withSecurityConf(t, tc.in, "")
|
||||
require.Equal(t, tc.want, cspMode(), "输入 %q", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPPolicy(t *testing.T) {
|
||||
old := conf.Upload
|
||||
t.Cleanup(func() { conf.Upload = old })
|
||||
|
||||
// 生产配置的真实写法:Host 带路径、Domain 是完整 URL。
|
||||
conf.Upload.ImageBucketCDNHost = "r2.example.net/tamabox"
|
||||
conf.Upload.AliyunBucketCDNHost = ""
|
||||
conf.Recaptcha.Domain = "https://www.recaptcha.net"
|
||||
|
||||
withSecurityConf(t, "enforce", "")
|
||||
|
||||
policy := cspPolicy()
|
||||
for _, want := range []string{
|
||||
"default-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
// 脚本白名单:站内 + 内联 + eval(Alpine 需要)+ 公共 CDN + 验证码
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"https://cdnjs.snrat.com",
|
||||
// umami 统计脚本是 header.html 里运行时动态注入的,静态 grep 模板看不到;
|
||||
// 少这一条会让 enforce 模式直接拦掉它(已在浏览器实测中复现过)。
|
||||
"https://mami.shiroko.one",
|
||||
"https://challenges.cloudflare.com",
|
||||
"https://www.recaptcha.net",
|
||||
// 带路径的配置必须被归一化成 origin,否则整条源非法、浏览器静默忽略
|
||||
"https://r2.example.net",
|
||||
} {
|
||||
require.Contains(t, policy, want)
|
||||
}
|
||||
require.NotContains(t, policy, "r2.example.net/tamabox", "带路径的配置必须裁成 origin")
|
||||
require.NotContains(t, policy, "report-uri", "没配上报地址时不应出现 report-uri")
|
||||
}
|
||||
|
||||
// TestCSPPolicySentry 覆盖「配了 sentry_dsn 才放行 Sentry CDN」这个条件白名单。
|
||||
// 两个方向都会静默出错:线上没开 Sentry 时白名单里多挂一个第三方源;开了 Sentry
|
||||
// 时错误上报被 enforce 直接拦掉,而页面上完全看不出异常。
|
||||
func TestCSPPolicySentry(t *testing.T) {
|
||||
old := conf.App
|
||||
t.Cleanup(func() { conf.App = old })
|
||||
|
||||
conf.App.SentryDSN = ""
|
||||
require.NotContains(t, cspPolicy(), "browser.sentry-cdn.com")
|
||||
|
||||
conf.App.SentryDSN = "https://[email protected]/1"
|
||||
require.Contains(t, cspPolicy(), "https://browser.sentry-cdn.com")
|
||||
}
|
||||
|
||||
func TestCSPPolicyReportURI(t *testing.T) {
|
||||
withSecurityConf(t, "report", "https://csp.example.net/collect")
|
||||
require.Contains(t, cspPolicy(), "report-uri https://csp.example.net/collect")
|
||||
}
|
||||
|
||||
func TestIsNoStorePath(t *testing.T) {
|
||||
for _, p := range []string{"/user", "/user/", "/user/profile", "/_", "/_/someone", "/_/someone/1"} {
|
||||
require.True(t, isNoStorePath(p), p)
|
||||
}
|
||||
// 分享卡是给社交平台抓取的公开图片,且渲染成本高,刻意不套 no-store。
|
||||
for _, p := range []string{"/", "/login", "/static/css/base.css", "/_foo", "/users", "/explore",
|
||||
"/_/someone/share-card", "/_/someone/1/share-card"} {
|
||||
require.False(t, isNoStorePath(p), p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSPPolicyNoEmptyOrigin 防止配置留空时拼出非法源(如 "https://")。
|
||||
func TestCSPPolicyNoEmptyOrigin(t *testing.T) {
|
||||
old := conf.Upload
|
||||
t.Cleanup(func() { conf.Upload = old })
|
||||
conf.Upload.ImageBucketCDNHost = ""
|
||||
conf.Upload.AliyunBucketCDNHost = ""
|
||||
conf.Recaptcha.Domain = ""
|
||||
|
||||
policy := cspPolicy()
|
||||
require.False(t, strings.Contains(policy, "https:// ") || strings.HasSuffix(policy, "https://"), policy)
|
||||
}
|
||||
@@ -3,16 +3,38 @@ package route
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/flamego/session"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/route/vote"
|
||||
)
|
||||
|
||||
// tooManyRequests 是 429 的唯一出口。
|
||||
//
|
||||
// 原来两处中间件各写一遍,只有一句英文硬编码 —— 既没走 i18n(中文站给访客看英文),
|
||||
// 也没有 Retry-After,客户端只能靠猜什么时候可以重试(多数会立刻重试,把限流放大)。
|
||||
// Retry-After 取窗口长度:滑动窗口下真实可重试时间可能更早,这里是安全上界。
|
||||
//
|
||||
// 语言固定用站点默认:限流中间件跑在 Contexter 之前,此时拿不到已解析的界面语言,
|
||||
// 而 429 的 body 通常由前端 JS 展示、很少直接被阅读。
|
||||
func tooManyRequests(ctx flamego.Context, window time.Duration) {
|
||||
w := ctx.ResponseWriter()
|
||||
retry := int(window.Seconds())
|
||||
if retry < 1 {
|
||||
retry = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(i18n.T(i18n.Default, "common.tooManyRequests")))
|
||||
}
|
||||
|
||||
// rateLimiter provides a simple in-memory rate limiter using a sliding window.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
@@ -140,8 +162,7 @@ func rateLimitMiddleware(limit int, window time.Duration) flamego.Handler {
|
||||
}
|
||||
|
||||
if !rl.allow(ip) {
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
|
||||
tooManyRequests(ctx, window)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -156,8 +177,7 @@ func voteSubmitRateLimitMiddleware(limit int, window time.Duration) flamego.Hand
|
||||
anonymousIPRL := newRateLimiter(limit*10, window)
|
||||
|
||||
tooMany := func(ctx flamego.Context) {
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = ctx.ResponseWriter().Write([]byte("Too many requests. Please try again later."))
|
||||
tooManyRequests(ctx, window)
|
||||
}
|
||||
|
||||
return func(ctx flamego.Context, s session.Session) {
|
||||
|
||||
+64
-3
@@ -5,6 +5,7 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"encoding/gob"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -64,15 +65,51 @@ func securityHeaders() flamego.Handler {
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
// 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 里)是有代价换来的:蜜罐的封禁通道参数
|
||||
@@ -115,12 +152,33 @@ func newBanner() *autoban.Banner {
|
||||
if reason == autoban.ReasonHoneypot {
|
||||
reasonText = "蜜罐提交凭据"
|
||||
}
|
||||
expiresAt := time.Now().Add(duration)
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"ban_key": key,
|
||||
"reason": reasonText,
|
||||
"duration": duration.String(),
|
||||
"expires_at": time.Now().Add(duration).Format("2006-01-02 15:04:05"),
|
||||
"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("封禁记录落库失败")
|
||||
}
|
||||
}()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -204,6 +262,10 @@ func New() *flamego.Flame {
|
||||
},
|
||||
})
|
||||
|
||||
// 安全响应头必须注册在静态资源之前:flamego.Static 命中后会终结请求链,
|
||||
// 放在它后面等于静态资源拿不到 nosniff / HSTS。
|
||||
f.Use(securityHeaders())
|
||||
|
||||
f.Use(flamego.Static(flamego.StaticOptions{
|
||||
FileSystem: http.FS(static.FS),
|
||||
Prefix: "/static",
|
||||
@@ -213,7 +275,6 @@ func New() *flamego.Flame {
|
||||
},
|
||||
}))
|
||||
|
||||
f.Use(securityHeaders())
|
||||
f.Use(ipBlacklistMiddleware())
|
||||
f.Use(autobanMiddleware(ab, hp))
|
||||
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
|
||||
|
||||
Reference in New Issue
Block a user