Go net/url 入门:安全地拼 URL 和查询参数

用外部搜索接口示例讲 net/url 的基本用法,包括路径转义、查询参数、URL 解析和避免手写字符串拼接。

很多 HTTP 客户端 bug 都来自手写 URL。用户输入里有空格、斜杠、中文、&?,一旦直接字符串拼接,就可能生成错误 URL,甚至改变参数含义。Go 标准库的 net/url 可以安全地处理路径和查询参数。

本文用调用外部搜索接口的例子,讲 url.URLurl.ValuesPathEscape 和常见误区。

不推荐手写拼接

endpoint := "https://api.example.com/search?q=" + keyword + "&page=" + page

如果 keywordgo & rust,生成的 URL 会把 & rust 当成另一个参数。正确做法是让标准库编码。

使用 url.Values

func SearchURL(base string, keyword string, page int) (string, error) {
	u, err := url.Parse(base)
	if err != nil {
		return "", err
	}
	q := u.Query()
	q.Set("q", keyword)
	q.Set("page", strconv.Itoa(page))
	u.RawQuery = q.Encode()
	return u.String(), nil
}

调用:

u, err := SearchURL("https://api.example.com/search", "go & rust", 2)

生成的 query 会正确转义。url.Values 还支持多值参数:

q.Add("tag", "go")
q.Add("tag", "web")

编码后会出现多个 tag

路径参数要 PathEscape

如果用户 ID 是路径的一部分:

path := "/users/" + userID

当 userID 包含 / 时,路径层级就变了。应该:

path := "/users/" + url.PathEscape(userID)

查询参数用 url.Values,路径片段用 url.PathEscape。不要混用。QueryEscapePathEscape 面对空格等字符的编码细节不同,语义也不同。

Base URL 和路径拼接

可以用 ResolveReference,但要理解斜杠语义:

base, _ := url.Parse("https://api.example.com/v1/")
ref, _ := url.Parse("users")
fmt.Println(base.ResolveReference(ref).String())

输出:

https://api.example.com/v1/users

如果 base 没有末尾斜杠:

url.Parse("https://api.example.com/v1")

v1 会被当成文件名,解析相对路径时可能被替换。实际项目里,简单而清楚的方式是封装一个 join 函数,或者让配置里的 base URL 明确带版本根路径。

校验外部回调 URL

如果用户提交回调地址,不要只看字符串是否以 http 开头:

func ValidateWebhookURL(raw string) (*url.URL, error) {
	u, err := url.Parse(raw)
	if err != nil {
		return nil, err
	}
	if u.Scheme != "https" {
		return nil, errors.New("webhook must use https")
	}
	if u.Host == "" {
		return nil, errors.New("missing host")
	}
	return u, nil
}

是否允许内网地址、localhost、IP 地址,要看安全策略。公开平台通常要防 SSRF,不能让用户随便填内网地址。入门阶段至少要理解:URL 解析只是第一步,业务校验仍然需要。

测试 URL 构造

func TestSearchURL(t *testing.T) {
	got, err := SearchURL("https://api.example.com/search", "go & rust", 2)
	if err != nil {
		t.Fatal(err)
	}
	u, err := url.Parse(got)
	if err != nil {
		t.Fatal(err)
	}
	if u.Query().Get("q") != "go & rust" {
		t.Fatalf("url = %s", got)
	}
}

测试时不要硬比较完整 query 字符串顺序。url.Values.Encode() 会排序,但更稳的是 parse 回来检查语义。

追加路径时避免双斜杠

很多客户端会配置 base URL:

baseURL := "https://api.example.com/v1/"

业务代码再拼路径:

endpoint := strings.TrimRight(baseURL, "/") + "/users/" + url.PathEscape(id)

这种写法虽然朴素,但对固定 API 客户端很实用。比到处手写 baseURL + "/users" 更稳。可以封装到 client 方法里,让所有接口走同一套路径拼接逻辑。

func (c *Client) endpoint(parts ...string) string {
	escaped := make([]string, 0, len(parts))
	for _, part := range parts {
		escaped = append(escaped, url.PathEscape(part))
	}
	return strings.TrimRight(c.BaseURL, "/") + "/" + strings.Join(escaped, "/")
}

如果某个 part 本身包含多个路径层级,就不要用这个函数。API 设计里最好区分“路径模板”和“路径参数”。

不要记录完整敏感 URL

URL 查询参数里可能包含 token、邮箱、手机号。日志里记录外部调用时,最好只记录 scheme、host 和 path:

func safeURLForLog(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return "<invalid>"
	}
	return u.Scheme + "://" + u.Host + u.Path
}

排查接口问题通常不需要完整 query。需要时可以记录经过白名单筛选的参数,比如 page、limit,不要把所有 query 原样写进日志。

编码不是校验

url.Values 能正确编码参数,但它不会判断参数是否业务合法。比如 page 仍然要大于 0,redirect URL 仍然要检查域名白名单。编码解决的是格式问题,校验解决的是规则问题。两者都要有。

处理回跳地址

登录后回跳是 URL 校验的典型场景。不要允许任意外部地址:

func safeReturnPath(raw string) string {
	if raw == "" {
		return "/"
	}
	u, err := url.Parse(raw)
	if err != nil {
		return "/"
	}
	if u.IsAbs() || !strings.HasPrefix(u.Path, "/") {
		return "/"
	}
	return u.RequestURI()
}

这段代码只允许站内路径,避免用户被重定向到恶意站点。很多安全问题不是编码错误,而是把“外部 URL”和“站内路径”混在一起。函数名里写 Path,返回值也只允许 path,会让边界更清楚。

URL 测试要覆盖特殊字符

测试不要只用 abc。至少覆盖空格、中文、斜杠和 &

cases := []string{"go web", "中文", "a/b", "a&b"}

这些值能快速暴露手写拼接的问题。URL 相关代码越是看起来简单,越应该用特殊字符测试。

小结

Go 里构造 URL 时,不要手写字符串拼接。查询参数用 url.Values,路径片段用 url.PathEscape,完整 URL 用 url.Parse 解析和校验。外部输入的 URL 还要做业务安全检查。

URL 是 HTTP 调用的入口,小错误会变成难查的接口问题。把编码交给标准库,代码更稳,也更容易测试。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页