From e8ef3b3f01c9fcf77d695eceadb0d935a1185ffc Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 26 Jul 2026 19:56:50 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(webserver):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=99=BB=E5=BD=95/=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E9=A1=B5=20next=20=E5=8F=82=E6=95=B0=E7=9A=84=20DOM=20XSS=20?= =?UTF-8?q?=E4=B8=8E=E5=BC=80=E6=94=BE=E9=87=8D=E5=AE=9A=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 客户端补归一化:两个页面脚本都用 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 裸读 --- internal/webserver/auth_pages.go | 48 +++++++++- internal/webserver/auth_redirect_test.go | 108 +++++++++++++++++++++++ 2 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 internal/webserver/auth_redirect_test.go diff --git a/internal/webserver/auth_pages.go b/internal/webserver/auth_pages.go index 0987c69e..61ec3b6b 100644 --- a/internal/webserver/auth_pages.go +++ b/internal/webserver/auth_pages.go @@ -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 { ` } +// 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; diff --git a/internal/webserver/auth_redirect_test.go b/internal/webserver/auth_redirect_test.go new file mode 100644 index 00000000..228550b4 --- /dev/null +++ b/internal/webserver/auth_redirect_test.go @@ -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,", + "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) + } + } +}