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 ./... 全通过。
121 lines
4.1 KiB
Go
121 lines
4.1 KiB
Go
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)
|
|
}
|