Files
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

94 lines
3.7 KiB
Go
Raw Permalink 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.
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)
}
}