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:
tamakyi
2026-10-27 12:37:40 +08:00
parent a3cf99a8da
commit 7802bb2078
32 changed files with 1579 additions and 145 deletions
+134
View File
@@ -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
}