🐛 fix(webserver): 修复登录/初始化页 next 参数的 DOM XSS 与开放重定向

- 客户端补归一化:两个页面脚本都用 location.search 重读原始 next,服务端过滤对其完全无效。
  新增共用的 safeNextTarget(),用 URL 解析器做同源判定(javascript:/data: 的 origin 为 null),
  归一化后再交给 window.location.replace,堵住 ?next=javascript:... 在页面自身源内执行、
  进而携带会话 Cookie 调用 /api/invoke 读取全部已保存连接并执行任意 SQL 的链路
- 服务端补 /\ 缺口:抽出 isSafeLocalRedirect 供 resolvePostAuthRedirect 与
  buildAuthRedirectURL 共用。WHATWG URL 解析器对 special scheme 把反斜杠等价为斜杠,
  故 /\evil.com 会被解析成 //evil.com,原先仅查 // 前缀挡不住该变体
- 补回归测试:覆盖 javascript:/data:///evil.com//\evil.com 等载荷,
  并断言两个页面脚本不再出现未归一化的 next 裸读
This commit is contained in:
Syngnat
2026-07-26 19:56:50 +08:00
parent 6a21b75ff7
commit e8ef3b3f01
2 changed files with 152 additions and 4 deletions

View File

@@ -405,6 +405,21 @@ func wantsHTMLResponse(r *http.Request) bool {
return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html")
}
// isSafeLocalRedirect 判断 next 是否为可安全跳转的站内路径。
//
// 除了 "//host"(协议相对 URL之外还必须拒绝 "/\host"WHATWG URL 解析器对 http/https
// 这类 special scheme 把反斜杠等价于斜杠,因此 "/\evil.com" 会被浏览器解析成 "//evil.com"
// 而变成跨站跳转,仅检查 "//" 前缀挡不住这个变体。
func isSafeLocalRedirect(next string) bool {
if !strings.HasPrefix(next, "/") {
return false
}
if strings.HasPrefix(next, "//") || strings.HasPrefix(next, `/\`) {
return false
}
return true
}
func resolvePostAuthRedirect(r *http.Request) string {
if r == nil {
return "/"
@@ -413,7 +428,7 @@ func resolvePostAuthRedirect(r *http.Request) string {
if next == "" {
return "/"
}
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
if !isSafeLocalRedirect(next) {
return "/"
}
return next
@@ -422,7 +437,7 @@ func resolvePostAuthRedirect(r *http.Request) string {
func buildAuthRedirectURL(target string, next string) string {
values := url.Values{}
normalizedNext := strings.TrimSpace(next)
if normalizedNext != "" && strings.HasPrefix(normalizedNext, "/") && !strings.HasPrefix(normalizedNext, "//") {
if normalizedNext != "" && isSafeLocalRedirect(normalizedNext) {
values.Set("next", normalizedNext)
}
if encoded := values.Encode(); encoded != "" {
@@ -917,6 +932,29 @@ func renderLoginBody(localizer *i18n.Localizer) string {
</div>`
}
// authSafeNextTargetScript 是登录页与初始化页共用的 next 参数归一化脚本。
//
// 这两个页面都在客户端用 location.search 重新读取原始 next因此服务端
// resolvePostAuthRedirect / buildAuthRedirectURL 的过滤对它们完全无效,必须在客户端再做一次。
// 未归一化时 ?next=javascript:... 会经 window.location.replace(nextTarget) 在页面自身源内执行
// HTML 规范只阻止跨源的 javascript: 导航,同文档自导航是允许的,且返回 undefined 时页面不跳转,
// 更难察觉),进而可携带会话 Cookie 调用 /__gonavi/api/invoke 读取全部已保存连接并执行任意 SQL。
//
// 用 URL 解析器判定同源而非字符串前缀javascript:/data: 这类 URL 的 origin 为 "null"
// 与页面 origin 不等;同时一并挡掉 //evil.com、/\evil.com、https://evil.com。
const authSafeNextTargetScript = `
function safeNextTarget(raw) {
try {
const parsed = new URL(String(raw || '/'), window.location.origin);
if (parsed.origin !== window.location.origin) {
return '/';
}
return parsed.pathname + parsed.search + parsed.hash;
} catch (err) {
return '/';
}
}`
func renderLoginScript(localizer *i18n.Localizer) string {
return `
const i18n = ` + mustJSON(map[string]string{
@@ -928,7 +966,8 @@ const errorEl = document.getElementById('error');
const formEl = document.getElementById('login-form');
const submitEl = document.getElementById('submit');
const codeWrapEl = document.getElementById('code-wrap');
const nextTarget = new URLSearchParams(window.location.search).get('next') || '/';
` + authSafeNextTargetScript + `
const nextTarget = safeNextTarget(new URLSearchParams(window.location.search).get('next'));
function showError(message) {
errorEl.textContent = message || i18n.loginFailed;
@@ -1140,7 +1179,8 @@ const totpConfigEl = document.getElementById('totp-config');
const totpDisabledNoteEl = document.getElementById('totp-disabled-note');
const stepButtons = Array.from(document.querySelectorAll('[data-step-target]'));
const stepPanels = Array.from(document.querySelectorAll('[data-step-panel]'));
const nextTarget = new URLSearchParams(window.location.search).get('next') || '/';
` + authSafeNextTargetScript + `
const nextTarget = safeNextTarget(new URLSearchParams(window.location.search).get('next'));
let bootstrapState = null;
let currentStep = 0;
const lastStepIndex = stepPanels.length - 1;

View File

@@ -0,0 +1,108 @@
package webserver
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
// TestIsSafeLocalRedirectRejectsOffSiteTargets 覆盖服务端 next 参数的站内判定。
//
// 回归背景:原判定只拒绝 "//host",漏掉 "/\host"。WHATWG URL 解析器对 http/https 这类
// special scheme 把反斜杠等价于斜杠,因此 "/\evil.com" 会被浏览器解析成 "//evil.com"
// 而变成跨站跳转。
func TestIsSafeLocalRedirectRejectsOffSiteTargets(t *testing.T) {
safe := []string{"/", "/table/x", "/table/x?a=1#f", "/setup"}
for _, next := range safe {
if !isSafeLocalRedirect(next) {
t.Errorf("isSafeLocalRedirect(%q) = false期望 true", next)
}
}
unsafe := []string{
"//evil.com",
`/\evil.com`,
"https://evil.com",
"http://evil.com",
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"evil.com",
"",
}
for _, next := range unsafe {
if isSafeLocalRedirect(next) {
t.Errorf("isSafeLocalRedirect(%q) = true期望 false", next)
}
}
}
// TestResolvePostAuthRedirectNormalizesUnsafeNext 服务端跳转必须归一到 "/"。
func TestResolvePostAuthRedirectNormalizesUnsafeNext(t *testing.T) {
cases := map[string]string{
"/table/x": "/table/x",
"//evil.com": "/",
`/\evil.com`: "/",
"javascript:x=1": "/",
"https://evil.com": "/",
"": "/",
}
for next, want := range cases {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
q := req.URL.Query()
if next != "" {
q.Set("next", next)
}
req.URL.RawQuery = q.Encode()
if got := resolvePostAuthRedirect(req); got != want {
t.Errorf("resolvePostAuthRedirect(next=%q) = %q期望 %q", next, got, want)
}
}
}
// TestBuildAuthRedirectURLDropsUnsafeNext 构造跳转链接时不得回传不安全的 next。
func TestBuildAuthRedirectURLDropsUnsafeNext(t *testing.T) {
if got := buildAuthRedirectURL("/login", `/\evil.com`); got != "/login" {
t.Errorf(`buildAuthRedirectURL("/login", "/\\evil.com") = %q期望 "/login"(不应回传不安全 next`, got)
}
if got := buildAuthRedirectURL("/login", "//evil.com"); got != "/login" {
t.Errorf(`buildAuthRedirectURL 未丢弃 "//evil.com",得到 %q`, got)
}
if got := buildAuthRedirectURL("/login", "/table/x"); !strings.Contains(got, "next=%2Ftable%2Fx") {
t.Errorf("buildAuthRedirectURL 未保留安全 next得到 %q", got)
}
}
// TestAuthPageScriptsNormalizeNextTargetBeforeNavigation 覆盖真正的 sink
// 两个页面脚本都在客户端用 location.search 重读原始 next服务端过滤对它们无效
// 必须经 safeNextTarget 归一化后才能交给 window.location.replace。
func TestAuthPageScriptsNormalizeNextTargetBeforeNavigation(t *testing.T) {
localizer, err := i18n.NewLocalizer("zh-CN")
if err != nil {
t.Fatalf("构造 localizer 失败:%v", err)
}
scripts := map[string]string{
"login": renderLoginScript(localizer),
"setup": renderSetupScript(localizer),
}
for name, script := range scripts {
if !strings.Contains(script, "function safeNextTarget(") {
t.Errorf("%s 页脚本缺少 safeNextTarget 归一化函数", name)
}
if !strings.Contains(script, "const nextTarget = safeNextTarget(") {
t.Errorf("%s 页脚本未把 nextTarget 交给 safeNextTarget 归一化", name)
}
// 不允许再出现未经归一化的裸读法。
if strings.Contains(script, "get('next') || '/'") {
t.Errorf("%s 页脚本仍存在未归一化的 next 裸读", name)
}
// 归一化必须用 URL 解析器做同源判定,而非字符串前缀。
if !strings.Contains(script, "parsed.origin !== window.location.origin") {
t.Errorf("%s 页脚本未用 origin 比较做同源判定", name)
}
}
}