Files
tamabox/internal/template/markdown.go
T
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

386 lines
13 KiB
Go
Raw 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.
// 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 删除块级元素闭合标签紧前的 <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 仅允许 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 让「回车」直接换行(<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 前必须重新转义,
// 否则 &lt;script&gt; 这类输入会在 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
}
// img 必须有合法的 src,否则整段丢弃:只删掉 src 属性会留下 <img alt="...">,
// 浏览器渲染成空占位(破图),既难看又没意义。
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):<img> 会在页面加载时无条件发起请求,
// 外链图片等于把「谁在什么时候、从哪个 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,
}
// <bucket>.<endpoint> 形态:阿里云 OSS 默认域名。
// 同时把 endpoint 主机本身也纳入候选 —— R2 / MinIO 走 path-style,
// 上传返回的链接是 <endpoint>/<bucket>/...,拼 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 校验 <img src>:只允许站内相对路径,或指向自己图片出口的 http(s) URL。
//
// 背景(P1-1):safeURL 放行任意 https:// 主机,而浏览器加载页面时会无条件拉取 <img>,
// 于是匿名提问者只要塞一张唯一 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())
}