rest: add URLJoinRoot to join a path onto a URL without changing its host GHSA-xqmq-v4wj-rw5m CVE-PENDING

rest.URLJoin resolves its path as a URL reference according to the URL
standard, so a path beginning with `//` is a network-path reference
which replaces the host (and port and userinfo) of the base URL. That
is often the wrong behaviour.

Add rest.URLJoinRoot which joins an unescaped root onto a base URL and
refuses any root which parses with a scheme, host or userinfo of its own.
This commit is contained in:
Nick Craig-Wood committed 2026-10-08 22:49:33 +01:00
1 parent 25707f2dc6
commit 28562c8a79
2 files changed
+60

No files matched your search

+20
View File
@@ -9,6 +9,10 @@ import (
// URLJoin joins a URL and a path returning a new URL
//
// path should be URL escaped
//
// path is resolved as a URL reference so an absolute URL or a
// network-path reference ("//host/path") replaces the scheme or host
// of base. Use URLJoinRoot for a path which must stay on base.
func URLJoin(base *url.URL, path string) (*url.URL, error) {
rel, err := url.Parse(path)
if err != nil {
@@ -17,6 +21,22 @@ func URLJoin(base *url.URL, path string) (*url.URL, error) {
return base.ResolveReference(rel), nil
}
// URLJoinRoot joins root, an unescaped path, onto base returning a new URL.
//
// root may only change the path of base. A root which carries a
// scheme, host or userinfo of its own, such as the network-path
// reference "//host/path", returns an error.
func URLJoinRoot(base *url.URL, root string) (*url.URL, error) {
rel, err := url.Parse(URLPathEscape(root))
if err != nil {
return nil, fmt.Errorf("error parsing %q as URL: %w", root, err)
}
if rel.Scheme != "" || rel.Opaque != "" || rel.User != nil || rel.Host != "" {
return nil, fmt.Errorf("root %q must not change the scheme, host or user of %q", root, base.Redacted())
}
return base.ResolveReference(rel), nil
}
// URLPathEscape escapes URL path the in string using URL escaping rules
//
// This mimics url.PathEscape which only available from go 1.8
+40
View File
@@ -44,6 +44,46 @@ func TestURLJoin(t *testing.T) {
}
}
func TestURLJoinRoot(t *testing.T) {
for i, test := range []struct {
base string
root string
wantOK bool
want string
}{
{"http://example.com/dir/", "", true, "http://example.com/dir/"},
{"http://example.com/dir/", "potato", true, "http://example.com/dir/potato"},
{"http://example.com/dir/", "sub/potato/", true, "http://example.com/dir/sub/potato/"},
{"http://example.com/dir/", "/potato", true, "http://example.com/potato"},
{"http://example.com/dir/", "../potato", true, "http://example.com/potato"},
{"http://example.com/dir/", "With colon :", true, "http://example.com/dir/With%20colon%20:"},
{"http://example.com/dir/", "http://example.com/dir/", true, "http://example.com/dir/http://example.com/dir/"},
{"http://user:pass@example.com/dir/", "potato", true, "http://user:pass@example.com/dir/potato"},
// network-path references are rejected, even for the same host
{"http://example.com/dir/", "//evil.example/", false, ""},
{"http://example.com/dir/", "//evil.example", false, ""},
{"http://example.com/dir/", "//evil.example/potato", false, ""},
{"http://example.com/dir/", "//example.com/dir/", false, ""},
{"http://example.com/dir/", "//example.com:8080/dir/", false, ""},
{"http://example.com/dir/", "//user:pass@example.com/dir/", false, ""},
{"http://user:pass@example.com/dir/", "//other:pass@example.com/dir/", false, ""},
{"http://user:pass@example.com/dir/", "//example.com/dir/", false, ""},
{"http://example.com/dir/", "//[::1]/potato", false, ""},
} {
u, err := url.Parse(test.base)
require.NoError(t, err)
got, err := URLJoinRoot(u, test.root)
gotOK := err == nil
what := fmt.Sprintf("test %d base=%q, root=%q", i, test.base, test.root)
assert.Equal(t, test.wantOK, gotOK, what)
var gotString string
if gotOK {
gotString = got.String()
}
assert.Equal(t, test.want, gotString, what)
}
}
func TestURLPathEscape(t *testing.T) {
for i, test := range []struct {
path string