net/url — URL 解析、拼接与安全
net/url 看着简单,实际是 Go 标准库里误用率最高的包之一:url.Parse 几乎不报错,导致大量代码把"解析成功"当成"URL 合法";转义在 query 和 path 两套规则不一致;SSRF、开放重定向、路径穿越几乎都从这里的误解长出来。这一篇按"选对入口 → 理解结构 → 转义规则 → 安全校验"的顺序讲。
1. 三个解析入口,选错就前功尽弃
| 入口 | 用途 | 严格程度 |
|---|---|---|
url.Parse(raw) |
解析任意 URL(含相对引用、mailto 这类不常见 scheme) | 最宽松:example.com/path、//evil.com/x 都不报错 |
url.ParseRequestURI(raw) |
解析 HTTP 请求行里的 URI:必须是绝对 URL,或以 / 开头的路径 |
拒绝 example.com/x(invalid URI for request),但接受 /just/path |
http.NewRequest(method, rawurl, body) |
客户端构造请求 | 内部就是 ParseRequestURI(GOROOT request.go 直接调用),所以漏写 http:// 会在这里报错而不是悄悄发出 |
实测对照(go1.27.0):
url.Parse("example.com/x") → Scheme="" Host="" Path="example.com/x" err=nil
url.ParseRequestURI("example.com/x") → err = parse "example.com/x": invalid URI for request
url.Parse("//evil.com/x") → Scheme="" Host="evil.com" Path="/x" err=nil
url.ParseRequestURI("//evil.com/x") → Scheme="" Host="" Path="//evil.com/x" err=nil//evil.com/x 这一行是开放重定向的经典形状:url.Parse 告诉你 Host 是 evil.com,而 ParseRequestURI 把它整段当路径。做跳转校验时,两处的结论不一致就说明"只判 err == nil"完全不可靠。
2. URL 结构:Path / RawPath / EscapedPath / String 的关系
type URL struct {
Scheme string
Opaque string // 不透明部分:mailto:someone@example.com 里的 "someone@example.com"
User *Userinfo
Host string // 可能含端口与 IPv6 方括号,如 "[::1]:8443"
Path string // 解码后的路径
RawPath string // 仅当原始转义形式与 Path 的默认编码不一致时才设置
RawQuery string // 原样的 query,未解码
Fragment string
ForceQuery bool // 需要保留结尾裸 "?"(String() 会补上)
}关键机制:Path 存解码后的值,RawPath 是"必须原样保留的编码形式"的覆盖项。EscapedPath() 的返回顺序是"RawPath 有效就用 RawPath,否则重新编码 Path";String() 走 EscapedPath()。实测:
输入 http://example.com/a%42b%7ec
Path="/aBb~c" ← %42 被解码成 B、%7e 被解码成 ~
RawPath="/a%42b%7ec" ← 因与默认编码不同而保留
String()="http://example.com/a%42b%7ec"
输入 http://example.com/a b
Path="/a b" RawPath="/a b" EscapedPath()="/a%20b"由此产生两条必须记住的结论:
u.Path是解码后的,可以直接和配置里的字符串比较;别拿u.String()做前缀/白名单判断(它会带 scheme、host、百分号编码后的形态)。- 手工构造
URL时Path与RawPath会打架。给Path塞..%2f之类的原始编码串,或只设RawPath不设Path,都可能造出"u.Path看起来安全、u.String()打出来完全不同"的对象。这是最隐蔽的一类 SSRF/绕过成因。
陷阱:
Opaque会被String()原样拼到scheme:之后。实测构造url.URL{Scheme:"https", Host:"example.com", Opaque:"//evil.com"},String()输出https://evil.com——Host字段直接被绕过。任何"允许业务代码自定义 URL 片段"的设计都要避免让用户控制Opaque。
不常见 scheme 的解析结果也常被误判:
mailto:someone@example.com?subject=hi
Scheme="mailto" Opaque="someone@example.com" Host="" Path="" RawQuery="subject=hi"用 if u.Host == "" { 拒绝 } 来筛"畸形 URL",会顺带拒绝所有合法的 mailto:/urn:;反过来,http:///path(三斜杠)解析出的 Host 是空字符串且不报错,此时"Host 为空即非法"的判断又恰好救你一命——前提是你在用解析结果而不是字符串。
3. 转义规则:query 和 path 是两套
实测对比同一个输入 "a b+c/d?e=f&g":
url.QueryEscape → a+b%2Bc%2Fd%3Fe%3Df%26g (空格→+,/ ? = & 全编码)
url.PathEscape → a%20b+c%2Fd%3Fe=f&g (空格→%20,+ ? = & 原样保留)以及编解码的对称性:
url.Values.Encode(): q=hello+world%2Bplus%26eq&q=second
url.ParseQuery(上面): 还原成 "hello world+plus&eq",往返一致
url.QueryUnescape(url.QueryEscape("a+b")): "a+b" ← 正确往返规则要点:
- query 里空格写成
+(QueryEscape/Values.Encode都这样),path 里空格是%20(PathEscape)。QueryUnescape会把+当空格,PathUnescape不会。所以把 query 的参数塞进路径、或反过来,都会静默改变值。 PathEscape不编码+、=、&(RFC 3986 允许它们出现在路径段里),但会编码?和/。想构造"用户可控的一段路径"时,光用PathEscape挡不住../——它编码了/从而挡下了../,但如果你手工拼接就前功尽弃(见 §4)。Values的编解码是对称的(实测往返相等):只要两侧都用net/url,不需要手工字符串操作。跨语言/跨系统时才要警惕:很多实现把+当字面量,%20才是通用形式。
3.1 ParseQuery 会"部分成功"
v, err := url.ParseQuery("a=1&b=&c&d=%zz&e")
// v = map[a:[1] b:[] c:[] e:[]],err = invalid URL escape "%zz"它继续解析剩余部分并返回第一个错误:d=%zz 这一对被丢弃,e 空值保留。危险在于"err 被忽略"的写法——参数会静默少一个。URL.Query() 更是连 error 都不返回:
u, _ := url.Parse("https://example.com/?a=1&b=%zz")
u.Query() // map[a:[1]] —— 你看不到 %zz 出错了对安全相关的参数(签名、权限位、回调地址),必须用 url.ParseQuery(r.URL.RawQuery) 并检查 error,而不是 r.URL.Query()。同理 r.Form 的解析在 ParseForm 出错时是 http.StatusBadRequest,别自己吞掉。
4. 拼接与解析:别用字符串加法
// 首选:包级 JoinPath,会做元素级转义并规整斜杠
full, err := url.JoinPath("https://api.example.com/v1/", "/a b", "c?d")
// → https://api.example.com/v1/a%20b/c%3Fd (空格和 ? 都被安全编码)
// 方法版:保留 base 的 query,只在 path 上追加
base, _ := url.Parse("https://api.example.com/v1/list?token=abc")
base.JoinPath("items", "42").String()
// → https://api.example.com/v1/list/items/42?token=abc字符串拼接的问题不是"不优雅",而是具体会坏:
u := "https://api.example.com/search?q=" + userInput
// userInput = "x&admin=1" → 多出一个参数
// userInput = "x#frag" → 后半段被当 fragment
// userInput = "//evil.com/x" → 若前面是 "https:" + userInput 这种写法,host 直接换掉需要携带参数时用 Values:
q := url.Values{}
q.Set("q", userInput) // Set 覆盖,Add 追加(同名多值)
q.Set("ids", "1,2")
u.RawQuery = q.Encode() // 顺序按 key 字典序,便于签名/缓存命中相对引用解析用 ResolveReference(没有 base.Resolve(ref) 这种别名了——URL.Resolve 早已废弃并从 API 中移除,go doc 里找不到它;老代码/老文章里见到就替换掉):
base, _ := url.Parse("https://api.example.com/v1/users/12")
ref, _ := url.Parse("//evil.com/x")
base.ResolveReference(ref).String() // https://evil.com/x —— 协议相对跳转
ref2, _ := url.Parse("/../../etc/passwd")
base.ResolveReference(ref2).String() // https://api.example.com/etc/passwd(.. 被清理)注意第二行:ResolveReference 会做路径规整(url.Clean 语义),所以 .. 不会留在结果里;但如果你的业务是"拿到相对路径后自己拼文件系统路径",规整发生在错误的层,照样是目录穿越。文件系统侧的防护见 os-file.md 的 filepath.IsLocal + os.Root。
4.1 复制与并发
URL 不是并发安全的(字段全公开),且 http.Request 的 URL 会被中间件改写。Go 1.27 新增了 Clone:
c := u.Clone() // nil 接收者返回 nil(实测),可安全用于可选字段
v := u.Query().Clone() // url.Values 也有 Clone(1.27)实测:改副本的 Host、替换 User 都不影响原对象(Userinfo 也是深拷贝)。1.27 之前只能 new(url.URL); *c = *u 浅拷贝——那种写法下 u.User 和 Values 的 map 仍然共享,是隐性数据 race。要放进日志/上下文前先 Clone。
5. 校验与安全:SSRF 与主机名判定
5.1 唯一可靠的姿势:解析后取 Hostname,再对照白名单
// 允许列表用"精确 host",不要用前缀/后缀匹配字符串
func safeTarget(raw string) (string, error) {
u, err := url.ParseRequestURI(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("disallowed scheme %q", u.Scheme)
}
if u.User != nil { // userinfo 里塞"白名单域名"是最常见的绕过
return "", errors.New("userinfo not allowed")
}
host := u.Hostname() // 去掉端口、去掉 IPv6 方括号
for _, allow := range allowedHosts {
if strings.EqualFold(host, allow) {
return u.String(), nil
}
}
return "", fmt.Errorf("host %q not allowed", host)
}三条实测证据说明为什么必须这样写:
url.Parse("http://trusted.com@evil.com/path")
→ Host="evil.com" User="trusted.com" ← "以白名单域名字符串结尾/包含"的写法全部沦陷
url.Parse("http://evil.com/#@trusted.com")
→ Host="evil.com" Fragment="@trusted.com" ← "字符串里包含 trusted.com"同样沦陷
url.Parse(`http://trusted.com\@evil.com`)
→ err = net/url: invalid userinfo ← 反斜杠形式 Go 会拒绝,但别的语言/代理未必正确判定永远基于解析后的字段:u.Hostname()、u.Scheme、u.User、u.Path,而不是原始字符串的 strings.Contains/HasSuffix。
上面这段函数对各类绕法的实测输出(go1.27.0,白名单只有 api.internal):
"https://api.internal/v1" -> 放行
"https://api.internal@evil.com/x" -> userinfo not allowed
"//evil.com/x" -> disallowed scheme ""
"ftp://api.internal/x" -> disallowed scheme "ftp"
"example.com/x" -> invalid URI for request
"https://api.internal.evil.com/p" -> host "api.internal.evil.com" not allowed最后一行是 EqualFold(host, allow) 精确匹配的价值:若写成 strings.HasSuffix(host, "api.internal") 或 "."+allow 包含判断,api.internal.evil.com 就直接过了。(顺带:无 userinfo 时 u.User 是 nil 而非空对象,实测确认,所以 u.User != nil 这道判断是有效的。)
5.2 端口与 IPv6:Go 1.26 收紧了 host 里的冒号
实测(go1.27.0):
url.Parse("http://localhost:80:80/") → err = invalid port ":80:80" after host
url.Parse("http://::1/") → err = invalid port "::1" after host
url.Parse("http://[::1]:8080/") → Host="[::1]:8080",方括号形式一直合法这是 Go 1.26 的变更:主机名里裸冒号被拒绝(此前会静默解析出一个奇怪的 Host)。回退开关 GODEBUG=urlstrictcolons=0,实测确认后一种行为:
$ ./urlprobe → parse=<nil> err=invalid port ":80:80" after host
$ GODEBUG=urlstrictcolons=0 ./urlprobe → parse=http://localhost:80:80/ err=<nil>顺带:url.Parse("https://exa mple.com/") 报 invalid character " " in host name;控制字符报 invalid control character in URL。这些"新报错"在升级 Go 版本时会以线上 500 的形式出现——不是 bug,是对端 URL 本来就不合法。
5.3 SSRF 的最后一环:解析完还要处理内网地址与 DNS
拿到 host 之后,SSRF 防护还要再走两步(很多实现漏掉这两步):
- 判断是否为内网/环回/链路本地地址——用
net.ParseIP+net.IP.IsLoopback()/IsPrivate()/IsLinkLocalUnicast(),或直接用netip(注意实测IsGlobalUnicast(net.ParseIP("192.168.1.1"))返回 true,别拿它当"公网地址"判据,见 net.md §1)。 - 先解析、再拨号解析出来的 IP,避免 DNS rebinding(白名单查询时是公网 IP、真正请求时变成 169.254.169.254)。做法与代码见 net.md 的 DNS 一节。
云环境里最值钱的目标是元数据地址(169.254.169.254、fd00:ec2::254),它同时是 link-local——IsLinkLocalUnicast() 判掉即可。
6. 与 net/http 的边界
- 服务端:
r.URL是请求行经url.ParseRequestURI的结果,通常 Scheme/Host 为空(绝对形式请求与OPTIONS *是例外)。权威主机名来自r.Host(Host 头,或 HTTP/2 的:authority)。判断"这是哪个域名"时用r.Host,别用r.URL.Host。 - Go 1.26 起 cookie 作用域按
Request.Host的 host 部分匹配(此前总是用连接地址的 host)。用 IP 直连 + 自定义 Host 头的场景会因此看到不同的 cookie 收发行为。 http.Redirect(w, r, "/login", 302)会走url.Parse+DetectRedirect;重定向目标里的..///host按 §4 的规则处理,别把用户输入直接塞进去。- 表单/query 统一入口:
r.ParseForm()(含 URL query;POST 表单还要ParseMultipartForm),r.FormValue会吞错误(取不到就返回空串),安全判定要回到r.Form/url.ParseQuery。 url.Values是map[string][]string,Get只取第一个值——同名多值是参数污染攻击面(?admin=0&admin=1)。严格接口应检测重复 key 并拒绝。
7. 顺带一提:net/mail
邮件地址解析不在 net/url 里,在 net/mail,够用就行:
a, err := mail.ParseAddress("Wang <wang@example.com>") // a.Name="Wang" a.Address="wang@example.com"
if _, err := mail.ParseAddress(s); err != nil { // 只判断语法(没有 mail.IsAddress 这个函数)
return errors.New("invalid email")
}
list, err := mail.ParseAddressList("a@x.com, b@y.com") // 逗号分隔列表
d, err := mail.ParseDate("Mon, 02 Jan 2006 15:04:05 -0700") // RFC 5322 时间net/mail 的导出 API 就这四个函数加 Address.String()(go doc 核实),所以"判断邮箱格式"只能写成 ParseAddress + 判 err。实用提醒:它只保证 RFC 5322 语法,不保证域名有 MX、更不保证邮箱存在——真实校验只能靠发验证邮件;另外 ParseAddress 能接受 "x@y" 这类没有 TLD 的地址,若业务要求严格格式,还需自己加一道判断。
8. 常见陷阱
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
把 err == nil 当"URL 合法" |
相对 URL、//evil.com 全过 |
明确要绝对 URL 就用 ParseRequestURI + 校验 Scheme |
用 strings.Contains/HasSuffix 校验 host |
http://trusted.com@evil.com 绕过 |
u.Hostname() 精确比对 |
忽略 u.User |
同上绕过 | 白名单场景直接拒绝带 userinfo 的 URL |
r.URL.Query() 判权限参数 |
解析错误被吞、参数丢失 | url.ParseQuery + 判 err |
只取 Values.Get |
?a=0&a=1 参数污染 |
检查 len(v[key]),重复即拒 |
| 字符串拼 query | 注入额外参数/fragment | url.Values.Encode() |
| 字符串拼 path | //host、../、编码绕过 |
url.JoinPath / URL.JoinPath |
浅拷贝 *copy = *u |
与并发请求共享 User/map,改一处影响全局 |
Go 1.27 u.Clone()(或手工重建) |
用 u.String() 做前缀判断 |
百分号编码差异导致判定与实际不符 | 用 u.Path/u.Hostname() 等字段 |
手设 Opaque/RawPath 造 URL |
String() 输出与字段不一致,可被用来绕过 |
让 url.Parse/JoinPath 生成,只设 Path |
升级 Go 后出现 invalid port ... after host |
1.26 起裸冒号 host 被拒 | 修数据;临时用 GODEBUG=urlstrictcolons=0 |
用 IsGlobalUnicast 判"公网" |
私网地址返回 true | IsPrivate()/IsLoopback()/IsLinkLocalUnicast 组合 |
认为 PathEscape 能挡住一切 |
它不编码 + = & |
用 JoinPath/Values,并在使用侧再做白名单校验 |
9. 实战建议
- 入口分层:解析外部输入 →
ParseRequestURI(或url.Parse+ 显式判 Scheme);构造对外请求 →JoinPath+Values.Encode;日志与序列化 →Redacted()(实测输出https://user:xxxxx@example.com:8443/p,把密码遮掉,绝不要打印String()原文,也不要用u.User自己拼)。 - 安全判定一律基于解析后的字段:Scheme、
Hostname()、Port()、User == nil、Path。任何基于原始字符串的匹配在长期演进中都会出现绕过,上面三条实测证据就是最常见的形状。 - SSRF 三件套:scheme 白名单 + host 白名单(精确匹配)+ 拒绝内网/环回/link-local,最后"先解析再按 IP 拨号"。少一环等于没做。
Clone之后再传:把 URL 放进 context、goroutine、审计日志时先u.Clone()(1.27+);老版本手工深拷贝要连User和 query map 一起重建。- 升级 Go 时把 URL 相关报错列进回归清单:
urlstrictcolons(1.26)以及更严格的 host 字符校验。看到invalid port/invalid character ... in host name先怀疑输入本身不合法,而不是回退开关。 - 相关手册:
net.md(netip判地址、DNSEntry/SSRF 的"先解析再拨号"、JoinHostPort/SplitHostPort)、net-http.md(Request.Host、body 防护、Server.Protocols)、os-file.md(filepath.IsLocal+os.Root防目录穿越)、regexp.md(不要用正则解析 URL)。