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:
@@ -0,0 +1,58 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SafeRedirectTarget 校验「跳转回原页面」这类参数,只放行站内绝对路径,不可信时返回空串。
|
||||
//
|
||||
// 为什么不能只判 `strings.HasPrefix(to, "/") && !strings.HasPrefix(to, "//")`:
|
||||
// WHATWG URL 在 special scheme(http/https)下把 `\` 视作 `/`,于是
|
||||
// `Location: /\evil.example` 会被浏览器解析成 `https://evil.example/` —— 一个可用的
|
||||
// 钓鱼跳板("登录后跳转异常,请重新输入密码")。浏览器在解析前还会剔除 URL 内部的
|
||||
// TAB/CR/LF,所以控制字符也必须先拒掉,否则服务端的判断与浏览器的解释不一致。
|
||||
//
|
||||
// 全站只有两个地方接受「跳到哪」的参数(登录后的 to、页面密码通过后的 next),
|
||||
// 两处都必须走这里 —— 之前它们各写一份判断,其中一份就漏了反斜杠。
|
||||
//
|
||||
// 返回空串表示「参数不可信」,由调用方回退到默认落地页。
|
||||
func SafeRedirectTarget(to string) string {
|
||||
if to == "" {
|
||||
return ""
|
||||
}
|
||||
// 控制字符与空格:前者会被浏览器剔除后重新解析,后者会被编码成 %20 改变语义。
|
||||
if strings.ContainsAny(to, "\\\x00\r\n\t ") {
|
||||
return ""
|
||||
}
|
||||
// 必须以单个斜杠开头:排掉相对路径、协议相对(//host)、绝对 URL(scheme:)。
|
||||
if !strings.HasPrefix(to, "/") || strings.HasPrefix(to, "//") {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(to)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// 解析成带 scheme / host / userinfo 的形态,说明上面两条判断被编码绕过了。
|
||||
if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" {
|
||||
return ""
|
||||
}
|
||||
// url.Parse 会把 %5c、%0d%0a 等编码解码进 Path。上面那轮检查看的是原始字符串,
|
||||
// 看不到解码后的形态,所以这里对解码结果再查一遍:反斜杠(浏览器当 /)与全部
|
||||
// C0 控制字符(响应头注入面,%0d%0a 能在 Location 里伪造新的一行头)。
|
||||
for _, r := range u.Path {
|
||||
if r == '\\' || r < 0x20 || r == 0x7f {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
// 归一化后再确认一次仍是站内绝对路径(吃掉 /..、/../x 之类的残留)。
|
||||
cleaned := path.Clean(u.Path)
|
||||
if !strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "//") {
|
||||
return ""
|
||||
}
|
||||
if u.RawQuery == "" {
|
||||
return cleaned
|
||||
}
|
||||
return cleaned + "?" + u.RawQuery
|
||||
}
|
||||
Reference in New Issue
Block a user