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 ./... 全通过。
295 lines
11 KiB
Go
295 lines
11 KiB
Go
package template
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/conf"
|
||
)
|
||
|
||
func TestMarkdownToHTML_Basic(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string // 必须包含
|
||
}{
|
||
{"bold", "**bold**", "<strong>bold</strong>"},
|
||
{"italic", "*italic*", "<em>italic</em>"},
|
||
{"link", "[baidu](https://baidu.com)", `href="https://baidu.com"`},
|
||
{"list", "- item1\n- item2", "<li>item1</li>"},
|
||
{"code", "`code`", "<code>code</code>"},
|
||
{"newline", "line1\nline2", "<br>"},
|
||
{"font-red", `<font color="red">文本</font>`, `<font color="red">文本</font>`},
|
||
{"font-hex", `<font color="#ff0000">红</font>`, `<font color="#ff0000">红</font>`},
|
||
{"font-rgb", `<font color="rgb(255,0,0)">红</font>`, `<font color="rgb(255,0,0)">红</font>`},
|
||
{"font-mixed", `**粗体** 和 <font color="blue">蓝</font>`, `<font color="blue">蓝</font>`},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := string(MarkdownToHTML(tc.in))
|
||
if !strings.Contains(got, tc.want) {
|
||
t.Errorf("MarkdownToHTML(%q) = %q, 期望包含 %q", tc.in, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMarkdownToHTML_XSS(t *testing.T) {
|
||
cases := []string{
|
||
`<script>alert(1)</script>`,
|
||
`&lt;script&gt;alert(1)&lt;/script&gt;`,
|
||
`<script>alert(1)</script>`,
|
||
`<img src=x onerror=alert(1)>`,
|
||
`[x](javascript:alert(1))`,
|
||
`<a href="javascript:alert(1)">x</a>`,
|
||
`<iframe src="https://evil.com"></iframe>`,
|
||
"<svg onload=alert(1)>",
|
||
`<div onclick="alert(1)">x</div>`,
|
||
`<style>body{display:none}</style>`,
|
||
}
|
||
for _, in := range cases {
|
||
got := string(MarkdownToHTML(in))
|
||
if strings.Contains(got, "<script") || strings.Contains(got, " onerror") ||
|
||
strings.Contains(got, " onclick") || strings.Contains(got, " onload") ||
|
||
strings.Contains(got, "<iframe") || strings.Contains(got, "javascript:") ||
|
||
strings.Contains(got, "<style") || strings.Contains(got, "<svg") {
|
||
t.Errorf("XSS 防护失败: 输入 %q 输出 %q", in, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestMarkdownToHTML_FontColorInjection font 的 color 属性只放行合法颜色值,
|
||
// 注入尝试必须被丢弃(标签保留但 color 属性被剥离,且不产生新属性)。
|
||
func TestMarkdownToHTML_FontColorInjection(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string // 输出必须包含(剥离后仅剩文本)
|
||
}{
|
||
{"css-inject", `<font color="red;position:fixed">x</font>`, "x"},
|
||
{"quote-inject", `<font color="red" onclick="alert(1)">x</font>`, "x"},
|
||
{"url-inject", `<font color="url(javascript:alert(1))">x</font>`, "x"},
|
||
{"onattr-inject", `<font color="x" style="background:url(javascript:1)">x</font>`, "x"},
|
||
{"long-value", `<font color="` + strings.Repeat("a", 50) + `">x</font>`, "x"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := string(MarkdownToHTML(tc.in))
|
||
if !strings.Contains(got, tc.want) {
|
||
t.Errorf("输出 %q 应包含 %q", got, tc.want)
|
||
}
|
||
// 注入的 color 值不得出现
|
||
for _, evil := range []string{";", "position:", "onclick", "javascript:", "<font color=\"red\" onclick", "<font color=\"x\" style"} {
|
||
if strings.Contains(got, evil) {
|
||
t.Errorf("注入残留: %q 包含 %q", got, evil)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMarkdownToHTML_Empty(t *testing.T) {
|
||
if got := string(MarkdownToHTML("")); got != "" {
|
||
t.Errorf("空输入应输出空串, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestMarkdownToPlainText(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string // 必须包含(忽略换行折叠)
|
||
}{
|
||
{"plain", "你好世界", "你好世界"},
|
||
{"bold", "**粗体**", "粗体"},
|
||
{"font", `<font color="red">红字</font>`, "红字"},
|
||
{"link", "[百度](https://baidu.com)", "百度"},
|
||
{"code", "`code`", "code"},
|
||
{"list", "- 第一项\n- 第二项", "第一项"},
|
||
{"block", "# 标题\n内容", "标题"},
|
||
{"mixed", "问题:**重要** 详情 <font color=\"blue\">蓝</font>", "重要"},
|
||
{"empty", "", ""},
|
||
// 危险标签被剥离:script 的内容是纯文本(绘制无风险),img 自闭合无文本则输出空
|
||
{"xss-script", "<script>alert(1)</script>", "alert(1)"},
|
||
{"xss-img", `<img src=x onerror=alert(1)>`, ""},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := strings.Join(strings.Fields(MarkdownToPlainText(tc.in)), " ")
|
||
if tc.want == "" {
|
||
if got != "" {
|
||
t.Errorf("MarkdownToPlainText(%q) = %q, 期望空", tc.in, got)
|
||
}
|
||
return
|
||
}
|
||
if !strings.Contains(got, tc.want) {
|
||
t.Errorf("MarkdownToPlainText(%q) = %q, 期望包含 %q", tc.in, got, tc.want)
|
||
}
|
||
// 输出不得残留任何标签(< 必须被剥离)
|
||
if strings.Contains(got, "<") {
|
||
t.Errorf("MarkdownToPlainText(%q) = %q, 不应包含标签字符", tc.in, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSafeURL 是 P2-1 的回归测试:markdown 链接白名单必须拒绝协议相对 URL
|
||
// (//evil.com 等),否则会被误判为站内相对路径而渲染成指向外站的钓鱼 / 外链埋点链接。
|
||
// 同时验证 javascript:/data:/ftp: 等危险协议与大小写变体均被拒绝,http/https/mailto
|
||
// 与站内相对路径被放行。
|
||
func TestSafeURL(t *testing.T) {
|
||
allow := []string{
|
||
"https://example.com",
|
||
"http://example.com",
|
||
"HTTPS://EXAMPLE.COM", // 大小写不敏感
|
||
"mailto:[email protected]",
|
||
"/path/to/page",
|
||
"./relative",
|
||
"../up",
|
||
"#fragment",
|
||
}
|
||
for _, u := range allow {
|
||
if !safeURL(u) {
|
||
t.Errorf("safeURL(%q) = false, 期望放行", u)
|
||
}
|
||
}
|
||
|
||
deny := []string{
|
||
"//evil.com", // 协议相对 URL(核心回归点)
|
||
`\\evil.com`, // 反斜杠变体
|
||
`/\evil.com`, // 反斜杠变体
|
||
"javascript:alert(1)", // 危险协议
|
||
"JavaScript:alert(1)", // 大小写变体
|
||
"data:text/html,<script>",
|
||
"ftp://example.com",
|
||
"", // 空串
|
||
}
|
||
for _, u := range deny {
|
||
if safeURL(u) {
|
||
t.Errorf("safeURL(%q) = true, 期望拒绝", u)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|