mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
🐛 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:
@@ -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;
|
||||
|
||||
108
internal/webserver/auth_redirect_test.go
Normal file
108
internal/webserver/auth_redirect_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user