diff --git a/lib/rest/url.go b/lib/rest/url.go index 3e6b7188d..4de634ad1 100644 --- a/lib/rest/url.go +++ b/lib/rest/url.go @@ -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 diff --git a/lib/rest/url_test.go b/lib/rest/url_test.go index df3519c13..aad664007 100644 --- a/lib/rest/url_test.go +++ b/lib/rest/url_test.go @@ -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