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) + } + } +}