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 ./... 全通过。
This commit is contained in:
@@ -8,11 +8,14 @@ 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 会把
|
||||
@@ -44,7 +47,8 @@ func PlainText(markdown string) string {
|
||||
|
||||
// MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤:
|
||||
// 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签;
|
||||
// href/src 仅允许 http/https/mailto;剥离所有 on* 事件属性。避免存储型 XSS。
|
||||
// href 仅允许 http/https/mailto,img 的 src 仅允许站内路径或配置里登记的图片主机
|
||||
// (见 safeImageSrc);剥离所有 on* 事件属性。避免存储型 XSS 与外链图片追踪。
|
||||
func MarkdownToHTML(markdown string) template.HTML {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return ""
|
||||
@@ -155,6 +159,12 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
|
||||
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" {
|
||||
@@ -184,7 +194,14 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) {
|
||||
}
|
||||
switch key {
|
||||
case "href", "src":
|
||||
if !safeURL(val) {
|
||||
// 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":
|
||||
@@ -250,3 +267,119 @@ func safeURL(u string) bool {
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user