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:
+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