Go URL 里的加号和斜杠变了:PathEscape 与 QueryEscape 各管哪个位置
对象编号里有加号、斜杠和空格,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中的字符。比只检查地址字符串里有没有百分号,更可靠的验收是接收方仍然拿到完全相同的逻辑值,并且路由边界符合预期。