// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package template import ( "bytes" stdhtml "html" "html/template" "net/url" "regexp" "strings" "github.com/russross/blackfriday/v2" "golang.org/x/net/html" "github.com/tamakyi/TamaBox/internal/conf" ) // blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的
:HardLineBreak 会把 // 块级元素末尾的换行也渲染成
,产生多余空行(行内换行保留)。 // RE2 不支持 lookahead,故对每个闭合标签单独编译。 var blockEndTags = []string{ "", "

", "", "", "", "", "", "", "", "", "", } var reBlockEndBrList = func() []*regexp.Regexp { res := make([]*regexp.Regexp, 0, len(blockEndTags)) for _, tag := range blockEndTags { res = append(res, regexp.MustCompile(`
[ \t\r\n]*(`+regexp.QuoteMeta(tag)+`)`)) } return res }() // reBlankLines 压缩连续换行为单个换行(MarkdownToPlainText 提取时块级/br 会产生多个 \n)。 var reBlankLines = regexp.MustCompile(`\n{2,}`) // PlainText 渲染为纯文本并压缩连续空行,供列表/摘要等紧凑场景使用。 func PlainText(markdown string) string { if strings.TrimSpace(markdown) == "" { return "" } return reBlankLines.ReplaceAllString(MarkdownToPlainText(markdown), "\n") } // MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤: // 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签; // href 仅允许 http/https/mailto,img 的 src 仅允许站内路径或配置里登记的图片主机 // (见 safeImageSrc);剥离所有 on* 事件属性。避免存储型 XSS 与外链图片追踪。 func MarkdownToHTML(markdown string) template.HTML { if strings.TrimSpace(markdown) == "" { return "" } raw := blackfriday.Run([]byte(markdown), // CommonExtensions 提供标准 Markdown;HardLineBreak 让「回车」直接换行(
), // 无需行尾双空格(对非技术用户更友好)。 blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.HardLineBreak), ) cleaned := sanitizeHTML(raw) // 清除块级元素末尾的冗余
(保留闭合标签,见 reBlockEndBrList 注释) for _, re := range reBlockEndBrList { cleaned = re.ReplaceAllString(cleaned, "$1") } return template.HTML(cleaned) } // MarkdownToPlainText 将 Markdown 渲染为纯文本(剥离所有标记符号与 HTML 标签), // 供分享图绘制、摘要等纯文本场景使用。块级元素之间以换行分隔(调用方可按需折叠)。 func MarkdownToPlainText(markdown string) string { if strings.TrimSpace(markdown) == "" { return "" } raw := blackfriday.Run([]byte(markdown), blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.HardLineBreak), ) return extractPlainText(raw) } // blockElements 提取纯文本时需在其后补换行的块级元素。 var blockElements = map[string]bool{ "p": true, "div": true, "h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "ul": true, "ol": true, "li": true, "blockquote": true, "pre": true, "table": true, "tr": true, } // extractPlainText 遍历 HTML token,只保留文本内容,块级元素与
后补换行。 func extractPlainText(raw []byte) string { var out strings.Builder tokenizer := html.NewTokenizer(bytes.NewReader(raw)) for { tt := tokenizer.Next() switch tt { case html.ErrorToken: return out.String() case html.TextToken: out.WriteString(string(tokenizer.Text())) case html.StartTagToken, html.SelfClosingTagToken: name := strings.ToLower(tokenizer.Token().Data) if name == "br" { out.WriteString("\n") } case html.EndTagToken: name := strings.ToLower(tokenizer.Token().Data) if blockElements[name] { out.WriteString("\n") } } } } var allowedTags = map[string]bool{ "p": true, "br": true, "hr": true, "div": true, "span": true, "strong": true, "em": true, "b": true, "i": true, "u": true, "s": true, "code": true, "pre": true, "blockquote": true, "ul": true, "ol": true, "li": true, "h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "a": true, "img": true, "table": true, "thead": true, "tbody": true, "tr": true, "th": true, "td": true, "del": true, "ins": true, // font 仅用于彩色文字(color 属性单独校验),无脚本能力,已废弃但浏览器仍支持 "font": true, } // sanitizeHTML 白名单过滤:遍历 token,重建安全 HTML。 func sanitizeHTML(raw []byte) string { var out bytes.Buffer tokenizer := html.NewTokenizer(bytes.NewReader(raw)) for { tt := tokenizer.Next() switch tt { case html.ErrorToken: return out.String() case html.TextToken: // ⚠️ Tokenizer.Text 返回已反转义文本,重建 HTML 前必须重新转义, // 否则 <script> 这类输入会在 template.HTML 结果中变成可执行标记。 out.WriteString(stdhtml.EscapeString(string(tokenizer.Text()))) case html.StartTagToken, html.SelfClosingTagToken: // SelfClosingTagToken 覆盖 `
` 自闭合写法(blackfriday 硬换行即此形式), // 漏掉会丢弃
、换行失效。 writeSanitizedTag(&out, tokenizer.Token(), false) case html.EndTagToken: writeSanitizedTag(&out, tokenizer.Token(), true) } } } func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) { name := strings.ToLower(tok.Data) if !allowedTags[name] { return } if isEnd { out.WriteString("") return } // img 必须有合法的 src,否则整段丢弃:只删掉 src 属性会留下 ..., // 浏览器渲染成空占位(破图),既难看又没意义。 if name == "img" && !imgSrcAllowed(tok.Attr) { return } // 反向 tabnabbing 防护:a 标签若使用 target="_blank",强制附带 rel="noopener noreferrer"。 hasTargetBlank := false if name == "a" { for _, attr := range tok.Attr { if strings.ToLower(attr.Key) == "target" && strings.TrimSpace(attr.Val) == "_blank" { hasTargetBlank = true break } } } out.WriteString("<" + name) for _, attr := range tok.Attr { key := strings.ToLower(attr.Key) val := strings.TrimSpace(attr.Val) if strings.HasPrefix(key, "on") { continue } // font 标签仅允许 color 属性,且值需通过颜色校验(防属性注入) if name == "font" { if key != "color" || !safeColorValue(val) { continue } out.WriteString(` color="` + stdhtml.EscapeString(val) + `"`) continue } switch key { case "href", "src": // img 的 src 单独收紧(P1-1): 会在页面加载时无条件发起请求, // 外链图片等于把「谁在什么时候、从哪个 IP 打开了页面」送给第三方。 // 因此只允许站内路径与自己的图片 CDN,不能复用面向 a[href] 的 safeURL。 if name == "img" && key == "src" { if !safeImageSrc(val) { continue } } else if !safeURL(val) { continue } case "target": // target 仅对 a 标签允许,且仅允许 _blank if name != "a" || val != "_blank" { continue } case "rel": // 若存在 target="_blank",忽略用户提供的 rel,统一由下方输出 noopener noreferrer if hasTargetBlank { continue } case "class", "alt", "title", "width", "height", "colspan", "rowspan", "start": // 保留有限的白名单属性(已移除 style,防止 CSS 注入 / 外联跟踪) default: continue } out.WriteString(" " + stdhtml.EscapeString(key) + `="` + stdhtml.EscapeString(val) + `"`) } if hasTargetBlank { out.WriteString(` rel="noopener noreferrer"`) } out.WriteString(">") } // safeColorValue 校验颜色值:仅允许颜色名 / #hex / rgb() / rgba() / hsl() 等形式, // 拒绝分号、引号、尖括号、斜杠、冒号等可能用于属性注入或 CSS 逃逸的字符。 func safeColorValue(v string) bool { if v == "" || len(v) > 40 { return false } for _, r := range v { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': continue case strings.ContainsRune("#,() .%", r): continue default: return false } } return true } // safeURL 仅允许 http/https/mailto 协议(含站内相对路径),拒绝 javascript: 等危险协议。 func safeURL(u string) bool { lower := strings.ToLower(strings.TrimSpace(u)) // 必须先于下面的 "/" 前缀判断:协议相对 URL(//evil.com)同样以 "/" 开头, // 不单独拦截会被误判为站内相对路径,[x](//evil.com) 即可指向外站(钓鱼 / 外链埋点)。 // 反斜杠变体(\\evil.com、/\evil.com)在部分浏览器中等价于 //,一并拒绝。 if strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, `\\`) || strings.HasPrefix(lower, `/\`) { return false } if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, "./") || strings.HasPrefix(lower, "../") || strings.HasPrefix(lower, "#") { return true } return false } // imgSrcAllowed 判断 img 标签是否带有一个合法的 src(缺省或非法则整段丢弃)。 func imgSrcAllowed(attrs []html.Attribute) bool { for _, attr := range attrs { if strings.ToLower(attr.Key) == "src" { return safeImageSrc(attr.Val) } } return false } // normalizeHostLike 把「配置里可能出现的各种主机写法」归一化成纯主机名: // // https://acc.r2.cloudflarestorage.com/tamabox → acc.r2.cloudflarestorage.com // r2.example.net/tamabox → r2.example.net // img.example.net:8443 → img.example.net // // 返回值统一小写、无端口、无路径、无尾点;无法解析时回退到去掉协议与路径的原文。 // // 为什么必须做这一步:上传配置里的 CDN 域名普遍写成「主机 + 路径」 // (如 image_bucket_cdn_host = "r2.example.net/tamabox",项目自带的示例就是这种写法), // 若直接拿去和 URL 的 Hostname() 比较,永远不相等 —— 表现为箱主自己上传的图片 // 也被渲染层整段丢弃(P1-1 的过度拦截)。 func normalizeHostLike(c string) string { c = strings.ToLower(strings.TrimSpace(c)) if c == "" { return "" } if !strings.Contains(c, "://") { c = "http://" + c } u, err := url.Parse(c) if err != nil || u.Hostname() == "" { // 兜底:至少去掉路径与尾点,别把整串配置当成主机名 if i := strings.IndexAny(c, "/?#"); i >= 0 { c = c[:i] } c = strings.TrimPrefix(c, "http://") return strings.TrimSuffix(c, ".") } return strings.TrimSuffix(u.Hostname(), ".") } // imageHostAllowed 判断主机名是否属于「我们自己的图片出口」: // 上传配置里的 CDN 域名、OSS / R2 默认域名,以及站点自身域名。 func imageHostAllowed(host string) bool { host = normalizeHostLike(host) if host == "" { return false } candidates := []string{ conf.Upload.ImageBucketCDNHost, conf.Upload.AliyunBucketCDNHost, conf.App.ExternalURL, } // . 形态:阿里云 OSS 默认域名。 // 同时把 endpoint 主机本身也纳入候选 —— R2 / MinIO 走 path-style, // 上传返回的链接是 //...,拼 bucket 前缀反而对不上。 for _, p := range [][2]string{ {conf.Upload.AliyunBucket, conf.Upload.AliyunEndpoint}, {conf.Upload.ImageBucket, conf.Upload.ImageEndpoint}, } { bucket, epHost := p[0], normalizeHostLike(p[1]) if epHost == "" { continue } candidates = append(candidates, epHost) if bucket != "" { candidates = append(candidates, bucket+"."+epHost) } } for _, c := range candidates { if normalizeHostLike(c) == host { return true } } return false } // safeImageSrc 校验 :只允许站内相对路径,或指向自己图片出口的 http(s) URL。 // // 背景(P1-1):safeURL 放行任意 https:// 主机,而浏览器加载页面时会无条件拉取 , // 于是匿名提问者只要塞一张唯一 URL 的外链图,箱主一打开,对方服务器就拿到了读取时间、 // 来源 IP 与 UA —— 等于给箱主装了「已读回执」。Referrer-Policy 只能挡住路径与 token, // 挡不住这两样。公开回答里的外链图还会把每个访客的 IP 送给第三方。 // 内容审核的「广告_引流」维度只在 strict 档生效,不能作为唯一防线,所以在渲染层收口。 func safeImageSrc(u string) bool { s := strings.TrimSpace(u) if s == "" { return false } // 浏览器把反斜杠等价于斜杠,任何含反斜杠的写法都直接拒绝(/\evil.com 等价 //evil.com)。 if strings.ContainsRune(s, '\\') { return false } // 协议相对(//evil.com)必须先于「以 / 开头 = 站内」的判断。 if strings.HasPrefix(s, "//") { return false } // 无协议部分 → 相对路径,必然同源,允许(/static/x.png、./x.png、x.png)。 if !strings.Contains(s, ":") { return true } parsed, err := url.Parse(s) if err != nil || parsed.Host == "" { return false } if parsed.Scheme != "http" && parsed.Scheme != "https" { return false } return imageHostAllowed(parsed.Hostname()) }