253 lines
8.3 KiB
Go
253 lines
8.3 KiB
Go
// 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"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/russross/blackfriday/v2"
|
||
"golang.org/x/net/html"
|
||
)
|
||
|
||
// blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的 <br>:HardLineBreak 会把
|
||
// 块级元素末尾的换行也渲染成 <br>,产生多余空行(行内换行保留)。
|
||
// RE2 不支持 lookahead,故对每个闭合标签单独编译。
|
||
var blockEndTags = []string{
|
||
"</li>", "</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>",
|
||
"</blockquote>", "</pre>", "</div>",
|
||
}
|
||
|
||
var reBlockEndBrList = func() []*regexp.Regexp {
|
||
res := make([]*regexp.Regexp, 0, len(blockEndTags))
|
||
for _, tag := range blockEndTags {
|
||
res = append(res, regexp.MustCompile(`<br>[ \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/src 仅允许 http/https/mailto;剥离所有 on* 事件属性。避免存储型 XSS。
|
||
func MarkdownToHTML(markdown string) template.HTML {
|
||
if strings.TrimSpace(markdown) == "" {
|
||
return ""
|
||
}
|
||
|
||
raw := blackfriday.Run([]byte(markdown),
|
||
// CommonExtensions 提供标准 Markdown;HardLineBreak 让「回车」直接换行(<br>),
|
||
// 无需行尾双空格(对非技术用户更友好)。
|
||
blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.HardLineBreak),
|
||
)
|
||
|
||
cleaned := sanitizeHTML(raw)
|
||
// 清除块级元素末尾的冗余 <br>(保留闭合标签,见 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,只保留文本内容,块级元素与 <br> 后补换行。
|
||
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 覆盖 `<br />` 自闭合写法(blackfriday 硬换行即此形式),
|
||
// 漏掉会丢弃 <br>、换行失效。
|
||
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("</" + name + ">")
|
||
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":
|
||
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
|
||
}
|