Skip to content
Go
net/url — URL 解析、拼接与安全

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 防护还要再走两步(很多实现漏掉这两步):

  1. 判断是否为内网/环回/链路本地地址——用 net.ParseIP + net.IP.IsLoopback()/IsPrivate()/IsLinkLocalUnicast(),或直接用 netip(注意实测 IsGlobalUnicast(net.ParseIP("192.168.1.1")) 返回 true,别拿它当"公网地址"判据,见 net.md §1)。
  2. 先解析、再拨号解析出来的 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)。