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 ./... 全通过。
94 lines
3.7 KiB
Go
94 lines
3.7 KiB
Go
package shared
|
||
|
||
import (
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestSafeRedirectTarget 锁定「跳转回原页面」参数的白名单行为。
|
||
//
|
||
// 重点是那批"看起来是站内路径、浏览器却会当外站"的载荷:反斜杠变体
|
||
// (WHATWG 在 http/https 下把 \ 当 /)、协议相对引用、以及编码后的同类载荷。
|
||
// 修复前两处调用点各写一份判断,其中登录那处用 `path.Clean("/"+to)`,只挡住 //host 与绝对 URL,
|
||
// 反斜杠直接漏到 Location 头(页面密码那处同样漏)。
|
||
func TestSafeRedirectTarget(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string // 空串表示"不可信,调用方回退默认落地页"
|
||
}{
|
||
// —— 合法站内路径:应当原样(或归一化后)放行 ——
|
||
{"箱主页", "/_/someone", "/_/someone"},
|
||
{"后台", "/user", "/user"},
|
||
{"单题页带 token", "/_/someone/12?t=abc", "/_/someone/12?t=abc"},
|
||
{"根路径", "/", "/"},
|
||
{"多点归一化", "/user/../explore", "/explore"},
|
||
{"编码斜杠归一化后仍在站内", "/%2f%2fevil.example", "/evil.example"},
|
||
|
||
// —— 必须拒绝的载荷 ——
|
||
{"空串", "", ""},
|
||
{"相对路径", "user", ""},
|
||
{"反斜杠跳转", `/\evil.example`, ""},
|
||
{"双反斜杠", `\\evil.example`, ""},
|
||
{"混入反斜杠的站内路径", `/_/a\b`, ""},
|
||
{"协议相对", "//evil.example", ""},
|
||
{"三斜杠", "///evil.example", ""},
|
||
{"绝对 http", "http://evil.example", ""},
|
||
{"绝对 https", "https://evil.example", ""},
|
||
{"同协议不同站", "https:/evil.example", ""},
|
||
{"javascript 伪协议", "javascript:alert(1)", ""},
|
||
{"data 伪协议", "data:text/html,<script>alert(1)</script>", ""},
|
||
{"userinfo 形态", "/@evil.example", "/@evil.example"}, // 站内路径,host 为空
|
||
{"编码反斜杠", `/%5cevil.example`, ""},
|
||
{"大写编码反斜杠", `/%5Cevil.example`, ""},
|
||
{"CRLF 注入", "/x%0d%0aSet-Cookie:%20a=b", ""},
|
||
{"内嵌 TAB", "/\t/evil.example", ""},
|
||
{"前导空格", " /evil.example", ""},
|
||
{"尾部空格", "/evil.example ", ""},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := SafeRedirectTarget(tc.in)
|
||
require.Equal(t, tc.want, got)
|
||
|
||
if got == "" {
|
||
return
|
||
}
|
||
// 不变式:返回值必须是浏览器解析后仍留在本站的路径。
|
||
// 只要 Host/Scheme 非空,或者以 // 开头,就会被浏览器当成外站。
|
||
require.True(t, strings.HasPrefix(got, "/"), "返回值必须以 / 开头: %q", got)
|
||
require.False(t, strings.HasPrefix(got, "//"), "返回值不能是协议相对引用: %q", got)
|
||
require.NotContains(t, got, `\`, "返回值不能含反斜杠: %q", got)
|
||
u, err := url.Parse(got)
|
||
require.NoError(t, err)
|
||
require.Empty(t, u.Host, "返回值不能带 host: %q", got)
|
||
require.Empty(t, u.Scheme, "返回值不能带 scheme: %q", got)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSafeRedirectTargetNeverLeavesSite 是全量不变式的兜底:
|
||
// 上面表的每一个输入(含被拒绝的)都必须满足"要么空、要么是站内绝对路径"。
|
||
func TestSafeRedirectTargetNeverLeavesSite(t *testing.T) {
|
||
payloads := []string{
|
||
"/\\evil.example", `\\evil.example`, "//evil.example", "///evil.example",
|
||
"https://evil.example", "http://evil.example", "//", "/%5c%5cevil.example",
|
||
"/\u0000evil", "/\u00a0evil", "\\\\evil.example/x", "/..//evil.example",
|
||
}
|
||
for _, p := range payloads {
|
||
got := SafeRedirectTarget(p)
|
||
if got == "" {
|
||
continue
|
||
}
|
||
u, err := url.Parse(got)
|
||
require.NoError(t, err)
|
||
require.True(t, strings.HasPrefix(got, "/") && !strings.HasPrefix(got, "//"), "载荷 %q 逃逸为 %q", p, got)
|
||
require.Empty(t, u.Host)
|
||
require.Empty(t, u.Scheme)
|
||
}
|
||
}
|