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
+123
View File
@@ -3,6 +3,8 @@ package template
import (
"strings"
"testing"
"github.com/tamakyi/TamaBox/internal/conf"
)
func TestMarkdownToHTML_Basic(t *testing.T) {
@@ -169,3 +171,124 @@ func TestSafeURL(t *testing.T) {
}
}
// TestSafeImageSrc 是 P1-1 的回归测试:<img src> 只允许站内相对路径与自己的图片出口。
//
// 危害:<img> 会在页面加载时无条件发起请求,匿名提问者塞一张唯一 URL 的外链图,
// 箱主一打开就暴露了读取时间 / IP / UA(等于已读回执);公开回答里的外链图还会
// 把每个访客的 IP 送给第三方。safeURL 面向 a[href],必须放行任意外站,故不能复用。
func TestSafeImageSrc(t *testing.T) {
oldCDN, oldExternal := conf.Upload.ImageBucketCDNHost, conf.App.ExternalURL
defer func() {
conf.Upload.ImageBucketCDNHost = oldCDN
conf.App.ExternalURL = oldExternal
}()
conf.Upload.ImageBucketCDNHost = "img.example.net"
conf.App.ExternalURL = "https://box.example.com"
allow := []string{
"/static/img/logo.webp",
"./local.png",
"../up.png",
"plain.png", // 无协议 → 同源相对路径
"https://img.example.net/picture/2026/x.webp",
"HTTPS://IMG.EXAMPLE.NET/picture/x.webp", // 主机名大小写不敏感
"https://img.example.net:443/picture/x.webp",
"http://box.example.com/static/x.png", // 站点自身域名
}
for _, u := range allow {
if !safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = false, 期望放行", u)
}
}
deny := []string{
"https://evil.example/x.png", // 任意外站:核心回归点
"//evil.example/x.png", // 协议相对
`\\evil.example/x.png`, // 反斜杠变体
`/\evil.example`, // 反斜杠变体(浏览器视作 //evil.example)
`https:/evil.example/x.png`, // 反斜杠 + 协议
"https://img.example.net.evil.example/x.png", // 后缀把戏
"https://[email protected]/x.png", // userinfo 把戏
"https://evil.example/img.example.net", // 域名出现在路径里
"javascript:alert(1)",
"data:image/png;base64,iVBORw0KGgo=",
"ftp://img.example.net/x.png",
"",
}
for _, u := range deny {
if safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = true, 期望拒绝", u)
}
}
}
// TestSafeImageSrc_RealConfiguredHosts 锁住「配置写成 host/path」这种真实形态。
//
// 回归点:只按 url.Parse(c).Host 判断配置是否带协议时,对
// "r2.example.net/tamabox" 这种写法得到空 Host,于是整串被当成主机名去比较,
// 永远不相等 —— 表现为箱主自己上传的图片也被渲染层整段丢弃(过度拦截)。
// 项目自带的 image_bucket_cdn_host 示例正是 host/path 写法,所以这不是边角情况。
func TestSafeImageSrc_RealConfiguredHosts(t *testing.T) {
old := conf.Upload
defer func() { conf.Upload = old }()
conf.Upload.ImageBucketCDNHost = "r2.example.net/tamabox"
conf.Upload.ImageBucket = "tamabox"
conf.Upload.ImageEndpoint = "https://acc123.r2.cloudflarestorage.com/tamabox"
conf.Upload.AliyunBucketCDNHost = ""
conf.Upload.AliyunBucket = ""
conf.Upload.AliyunEndpoint = ""
allow := []string{
"https://r2.example.net/tamabox/a.png", // CDN 配置写成 host/path
"https://acc123.r2.cloudflarestorage.com/tamabox/a.png", // path-style S3(R2 实际返回形态)
"https://tamabox.acc123.r2.cloudflarestorage.com/a.png", // virtual-hosted 形态
}
for _, u := range allow {
if !safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = false, 期望放行(自有图片出口)", u)
}
}
deny := []string{
"https://acc123.r2.cloudflarestorage.com.evil.example/a.png", // 后缀把戏
"https://evil.example/tamabox/a.png",
"https://r2.example.net.evil.example/tamabox/a.png",
}
for _, u := range deny {
if safeImageSrc(u) {
t.Errorf("safeImageSrc(%q) = true, 期望拒绝(非自有图片出口)", u)
}
}
}
// TestMarkdownToHTML_ImageSource 端到端锁定渲染结果:外链图片必须整段消失,
// 站内图片正常保留;同时确认 a[href] 的外站链接不受影响(需求上允许)。
func TestMarkdownToHTML_ImageSource(t *testing.T) {
oldCDN := conf.Upload.ImageBucketCDNHost
defer func() { conf.Upload.ImageBucketCDNHost = oldCDN }()
conf.Upload.ImageBucketCDNHost = "img.example.net"
external := string(MarkdownToHTML(`![a](https://tb-track.example.org/unique.png)`))
if strings.Contains(external, "tb-track.example.org") {
t.Fatalf("外链图片未被剥离:%s", external)
}
if strings.Contains(external, "<img") {
t.Fatalf("外链图片仍渲染出 <img>:%s", external)
}
rawHTML := string(MarkdownToHTML(`<img src="https://tb-track.example.org/raw.png">`))
if strings.Contains(rawHTML, "tb-track.example.org") {
t.Fatalf("原始 <img> 外链未被剥离:%s", rawHTML)
}
internal := string(MarkdownToHTML(`![a](https://img.example.net/picture/x.webp)`))
if !strings.Contains(internal, `<img src="https://img.example.net/picture/x.webp"`) {
t.Fatalf("站内图片被误删:%s", internal)
}
link := string(MarkdownToHTML(`[x](https://external.example.org/page)`))
if !strings.Contains(link, `href="https://external.example.org/page"`) {
t.Fatalf("a[href] 外链被误删:%s", link)
}
}