package shared import ( "net/url" "path" "strings" ) // SafeRedirectTarget 校验「跳转回原页面」这类参数,只放行站内绝对路径,不可信时返回空串。 // // 为什么不能只判 `strings.HasPrefix(to, "/") && !strings.HasPrefix(to, "//")`: // WHATWG URL 在 special scheme(http/https)下把 `\` 视作 `/`,于是 // `Location: /\evil.example` 会被浏览器解析成 `https://evil.example/` —— 一个可用的 // 钓鱼跳板("登录后跳转异常,请重新输入密码")。浏览器在解析前还会剔除 URL 内部的 // TAB/CR/LF,所以控制字符也必须先拒掉,否则服务端的判断与浏览器的解释不一致。 // // 全站只有两个地方接受「跳到哪」的参数(登录后的 to、页面密码通过后的 next), // 两处都必须走这里 —— 之前它们各写一份判断,其中一份就漏了反斜杠。 // // 返回空串表示「参数不可信」,由调用方回退到默认落地页。 func SafeRedirectTarget(to string) string { if to == "" { return "" } // 控制字符与空格:前者会被浏览器剔除后重新解析,后者会被编码成 %20 改变语义。 if strings.ContainsAny(to, "\\\x00\r\n\t ") { return "" } // 必须以单个斜杠开头:排掉相对路径、协议相对(//host)、绝对 URL(scheme:)。 if !strings.HasPrefix(to, "/") || strings.HasPrefix(to, "//") { return "" } u, err := url.Parse(to) if err != nil { return "" } // 解析成带 scheme / host / userinfo 的形态,说明上面两条判断被编码绕过了。 if u.Scheme != "" || u.Host != "" || u.User != nil || u.Opaque != "" { return "" } // url.Parse 会把 %5c、%0d%0a 等编码解码进 Path。上面那轮检查看的是原始字符串, // 看不到解码后的形态,所以这里对解码结果再查一遍:反斜杠(浏览器当 /)与全部 // C0 控制字符(响应头注入面,%0d%0a 能在 Location 里伪造新的一行头)。 for _, r := range u.Path { if r == '\\' || r < 0x20 || r == 0x7f { return "" } } // 归一化后再确认一次仍是站内绝对路径(吃掉 /..、/../x 之类的残留)。 cleaned := path.Clean(u.Path) if !strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "//") { return "" } if u.RawQuery == "" { return cleaned } return cleaned + "?" + u.RawQuery }