原创

Go URL 里的加号和斜杠变了:PathEscape 与 QueryEscape 各管哪个位置

  • 发布时间:2026-10-23 23:16:14
  • 本文热度:浏览 2 赞 0 评论 0
  • 全文共1字,阅读约需1分钟

对象编号里有加号、斜杠和空格,URL编码以后接口却收到了另一个编号。先检查调用的是PathEscape还是QueryEscape,再检查是否把已经转义的字符串塞进URL.Path让它转义了第二次。

路径段与查询值采用不同规则。查询中的加号常用于表达空格,路径中的加号则可以是字面加号;斜杠在路径层又可能分隔段。一个“统一URL编码”函数无法替所有位置决定语义。

下面使用Go 1.24.1启动本机测试服务,真正发一条请求,再把服务端解析到的键和值传回来。路由采用Go 1.22之后的路径参数模式,让一个含斜杠的编号仍作为单段值处理。

从原值生成 URL,而不是先拼转义文本

保存main.go,在独立模块执行go run .。程序使用httptest.Server分配本机端口,结束时关闭,不连接外部站点。

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "net/url"
)

type Seen struct { Key, Query, Path, RawPath string }

func objectURL(base, key, search string) (string, error) {
    target, err := url.Parse(base)
    if err != nil { return "", err }
    target.Path = "/objects/" + key
    target.RawPath = "/objects/" + url.PathEscape(key)
    target.RawQuery = url.Values{"q": []string{search}}.Encode()
    return target.String(), nil
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /objects/{key}", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        if err := json.NewEncoder(w).Encode(Seen{
            r.PathValue("key"), r.URL.Query().Get("q"), r.URL.Path, r.URL.RawPath,
        }); err != nil { panic(err) }
    })
    server := httptest.NewServer(mux)
    defer server.Close()
    const key = "a+b/c d"
    const search = "x+y z"
    address, err := objectURL(server.URL, key, search)
    if err != nil { panic(err) }
    response, err := server.Client().Get(address)
    if err != nil { panic(err) }
    defer response.Body.Close()
    if response.StatusCode != http.StatusOK { panic(response.Status) }
    var seen Seen
    if err := json.NewDecoder(response.Body).Decode(&seen); err != nil { panic(err) }
    if seen.Key != key || seen.Query != search { panic("identity changed") }
    fmt.Println("escaped path:", response.Request.URL.EscapedPath())
    fmt.Println("server key:", seen.Key)
    fmt.Println("server query:", seen.Query)
    fmt.Println("decoded URL.Path:", seen.Path)
    fmt.Println("preserved URL.RawPath:", seen.RawPath)
    pathPlus, err := url.PathUnescape("a+b")
    if err != nil { panic(err) }
    queryPlus, err := url.QueryUnescape("a+b")
    if err != nil { panic(err) }
    fmt.Printf("PathUnescape=%q QueryUnescape=%q\n", pathPlus, queryPlus)
    wrong := &url.URL{Scheme: "https", Host: "example.test", Path: "/objects/" + url.PathEscape(key)}
    fmt.Println("double escaped:", wrong.String())
    wrongPosition, err := url.PathUnescape(url.QueryEscape(key))
    if err != nil { panic(err) }
    fmt.Println("query escaping used as path:", wrongPosition)
    if pathPlus != "a+b" || queryPlus != "a b" || wrongPosition == key {
        panic("encoding contrast failed")
    }
}

服务端应得到原来的a+b/c d与x+y z。URL.Path呈现已经解码的路径,RawPath保留相应可选转义表示;路径值通过路由按段解码,不能只拿整个Path里的斜杠数猜它原本分成几段。

最后两项故意错误:预转义结果放进Path,百分号再次转义;QueryEscape用于路径段,原来的空格变成路径里的字面加号。请求没有必要报错才算损坏,只要身份字符串改变,后续就可能查错对象。

Path 存解码形式,RawPath 是可选编码提示

URL结构需要一份逻辑路径,String或EscapedPath负责生成相应转义文本。RawPath若提供,必须是与Path一致的合法转义表示,否则不会按调用者想象强制采用。

本例同时设置二者,是为了保留“编号内的斜杠已经转义”这项分段意图。只把key拼进Path,URL字符串会把其中斜杠当作普通路径分隔,路由不再必然把整个key作为一个参数。

RawPath不是一个适合随便手工拼接的第二真相。应让它来自相同原值的对应编码,并用真实请求回读验证。如果后续修改Path却保留旧RawPath,原先提示就可能失效,调试时又误以为服务器改变了编码。

库与代理也可能对路径执行规范化。对象存储等系统如果把双斜杠、点段或转义形式当作不透明键的一部分,应核对整条链路,而不只核对客户端URL.String。一次本机正确往返不证明所有代理都会保留同一语义。

查询由键值编码器处理,不用路径规则替代

url.Values.Encode为查询字段处理转义,并允许同名键有多个值。这里q只传一份,所以服务端Get足够;多选字段应使用完整切片,不能在解析时不加判断只取第一项。

Values是按键组织的数据结构,生成文本时有自己的排序规则。需要原始查询字节参与签名时,应明确规范,不能把解析再Encode当作原报文的逐字节副本。它适合构造语义正确参数,不负责恢复此前的文本形式。

字面加号在原值中存在时,由编码器生成%2B;空格则可以在查询中生成加号。手工拼接q=x+y再调用QueryUnescape,得到空格是按规则工作,并非解码器错把加号弄丢了。错误发生在原值没有先按所在位置编码。

服务端PathValue与Query解析结果已经完成相应解码,不应为了“保险”再统一QueryUnescape一遍。原本合法的字面加号可能在第二轮被当成空格,原编号中真正存在的百分号序列也可能被重新解释。接收端需要知道当前变量处于哪一层表示,重复调用不是额外安全检查。

也不要把整条URL交给PathEscape。协议、主机、路径分隔和查询分隔都承担结构作用,整串转义会改变结构本身。应先用URL结构表达各部分,再针对单个不透明段或查询值处理编码,让结构字符与数据字符有清楚边界。

编码还不会验证业务文本是否符合Unicode或编号规范。Go字符串可以承载字节序列,需要有效UTF-8的接口应在字段层明确验证;能生成一串转义字符,只证明这些字节获得了URL表示,不能证明它们是业务接受的文字。

不要让多次解码变成隐形协议

如果下游发现字段还带%2F就再解一次,上游又为了适配它多编码一次,链路会形成很难解释的双重处理。某些普通值恰好能恢复,包含百分号的真实编号却可能被改成另一个字符。

每一层应明确拿到的是原值、已编码路径段还是整条URL。变量名可以表达这一点,但真正保证来自边界转换只发生一次,以及使用含百分号、加号、空格、斜杠和中文的回归样本。

服务器授权也应基于最终明确解析出的业务键,不要用一份原路径做权限检查、另一份多次解码路径做资源访问。两层身份解释不一致时,即使每个工具函数单看都符合文档,系统行为仍然可能错误。

本文只传一个受控编号,未提供通用对象键规范化服务。若业务禁止斜杠或控制字符,应在业务键验证阶段拒绝,而不是依靠转义让所有字符串都变成可接受身份。URL能够表示与业务应该接受,是两项不同条件。

运行完保留服务端回读断言,再改变key中的字符。比只检查地址字符串里有没有百分号,更可靠的验收是接收方仍然拿到完全相同的逻辑值,并且路由边界符合预期。

正文到此结束
评论插件初始化中...
Loading...