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:
@@ -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(``))
|
||||
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(``))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user