Files
tamakyi 7802bb2078 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 ./... 全通过。
2026-10-27 12:37:40 +08:00

295 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{
`&lt;script&gt;alert(1)&lt;/script&gt;`,
`&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;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(`![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)
}
}