Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 71 additions & 24 deletions remote/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,34 +154,81 @@ func (c *Client) executeWithRetry(ctx context.Context, method, path string, body
return lastResp, lastErr
}

func (c *Client) executeOnce(ctx context.Context, method, path string, body []byte, extraHeaders map[string]string) (*http.Response, error) {
finalURL := path

// Handle URL construction and verification
// resolveURL turns a caller-supplied path into the absolute URL to request.
//
// An absolute path is used verbatim once its scheme and host are checked
// against the configured service. A relative one is resolved against the base
// URL with net/url rather than string concatenation, so separators, escaping
// and dot-segments follow the same rules the rest of the world uses.
func (c *Client) resolveURL(path string) (string, error) {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
// Absolute URL validation
if c.baseURL != "" {
base, err := url.Parse(c.baseURL)
if err != nil {
return nil, fmt.Errorf("remote: invalid base URL: %w", err)
}
if c.baseURL == "" {
return path, nil
}

provided, err := url.Parse(path)
if err != nil {
return nil, fmt.Errorf("remote: invalid absolute URL: %w", err)
}
base, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("remote: invalid base URL: %w", err)
}
provided, err := url.Parse(path)
if err != nil {
return "", fmt.Errorf("remote: invalid absolute URL: %w", err)
}

// Ensure Scheme and Host match to prevent SSRF or credential leakage
if base.Scheme != provided.Scheme || base.Host != provided.Host {
return nil, fmt.Errorf("remote: absolute URL host %q does not match configured service host %q", provided.Host, base.Host)
}
// Ensure Scheme and Host match to prevent SSRF or credential leakage
if base.Scheme != provided.Scheme || base.Host != provided.Host {
return "", fmt.Errorf("remote: absolute URL host %q does not match configured service host %q", provided.Host, base.Host)
}
} else {
// Relative path handling
// Ensure baseURL ends with / and path doesn't start with / to avoid double slashes or missing slashes
base := strings.TrimSuffix(c.baseURL, "/")
p := strings.TrimPrefix(path, "/")
finalURL = base + "/" + p
return path, nil
}

base, err := url.Parse(c.baseURL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this now parses c.baseURL on every request in the relative-path branch, where the previous code did no parsing at all here — and it's on the hot path, since most calls are relative rather than absolute.

Suggested fix: baseURL is immutable after NewClient (no Option in options.go touches it), and url.URL.JoinPath never mutates its receiver, so a single parsed *url.URL can safely be cached on Client and reused across concurrent requests with no extra locking. One wrinkle: NewClient has no error return today, so this isn't quite "fail fast at construction" — the practical version is to parse once, store both the *url.URL and any parse error, and return the stored error from resolveURL on first use, same as today's behavior but without re-parsing every call.

if err != nil {
return "", fmt.Errorf("remote: invalid base URL: %w", err)
}
// Parsing splits the path from any query or fragment the caller appended to
// it (JSONRequest appends the encoded Query this way), so each part can be
// carried on the URL it belongs to instead of being spliced into a string.
ref, err := url.Parse(path)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

url.Parse here reads a path segment like TASK:123 or ns:foo as scheme:opaque per RFC 3986, not as a literal path — and since resolveURL only reads EscapedPath()/RawQuery/Fragment off the result, an opaque parse means EscapedPath() comes back empty and the segment is silently dropped, sending the request to the bare base URL instead of the intended resource with no error at all. A digit-led variant (12:34) fails the other way — url.Parse rejects it outright ("first path segment in URL cannot contain colon"), breaking a path that worked before this change. Verified directly against this branch's resolveURL: path="TASK:123" resolves to http://example.com/api/reports (path silently dropped, no error); path="12:34" returns an error. Neither case is in the new test table.

Suggested fix: parse "./"+path instead of path. This isn't a workaround — RFC 3986 §4.2 documents this exact ambiguity and prescribes exactly this escape: "A path segment that contains a colon character... cannot be used as the first segment of a relative-path reference, as it would be mistaken for a scheme name. Such a segment must be preceded by a dot-segment (e.g., ./this:that)." JoinPath's internal path.Clean strips the ./ back out for free, so no extra stripping is needed. The root-path check at line 206 would need p == "./" instead of p == "/" to match. Confirmed this fix against all 10 existing test cases plus TASK:123, ns:foo, and 12:34 — all resolve correctly.

if err != nil {
return "", fmt.Errorf("remote: invalid request path %q: %w", path, err)
}

// EscapedPath, not Path: joining the decoded form would turn an escaped
// separator ("x%2Fy") into a real one, silently addressing a different
// resource than the caller asked for.
p := ref.EscapedPath()

// JoinPath preserves a trailing separator, so a root-only path would
// produce "<base>/". That names a different resource than "<base>" and
// some servers reject it, and a caller reaching the service URL itself has
// no other way to say so — treat it as empty.
if p == "/" {
p = ""
}

u := base.JoinPath(p)

// A query on the base URL is unusual but not illegal, so merge rather than
// let either side silently win.
switch {
case ref.RawQuery == "":
case u.RawQuery == "":
u.RawQuery = ref.RawQuery
default:
u.RawQuery += "&" + ref.RawQuery
}
if ref.Fragment != "" {
u.Fragment = ref.Fragment
}

return u.String(), nil
}

func (c *Client) executeOnce(ctx context.Context, method, path string, body []byte, extraHeaders map[string]string) (*http.Response, error) {
finalURL, err := c.resolveURL(path)
if err != nil {
return nil, err
}

var bodyReader io.Reader
Expand Down
94 changes: 94 additions & 0 deletions remote/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,100 @@ func TestClient_BaseURL_Logic(t *testing.T) {
assert.Error(t, err2)
assert.Contains(t, err2.Error(), "does not match configured service host")
})

// An empty path addresses the service URL itself. Appending a separator
// would request a different resource, and some servers answer the trailing
// form with 405.
t.Run("relative path joins without a trailing separator", func(t *testing.T) {
for _, tc := range []struct {
name string
basePath string
path string
query url.Values
wantPath string
wantQuery string
}{
{name: "empty path posts to the service URL itself", basePath: "/api/reports", path: "", wantPath: "/api/reports"},
{name: "root path is treated as empty", basePath: "/api/reports", path: "/", wantPath: "/api/reports"},
{name: "non-empty path is appended", basePath: "/api", path: "reports", wantPath: "/api/reports"},
{name: "leading separator is not doubled", basePath: "/api", path: "/reports", wantPath: "/api/reports"},

// Query parameters are appended to the path before it reaches the
// join, so the empty-path rule has to look past them: a path of
// "?a=1" still addresses the service URL itself.
{
name: "empty path with query keeps the service URL itself",
basePath: "/api/reports",
path: "",
query: url.Values{"id": {"42"}},
wantPath: "/api/reports",
wantQuery: "id=42",
},
{
name: "root path with query is treated as empty",
basePath: "/api/reports",
path: "/",
query: url.Values{"id": {"42"}},
wantPath: "/api/reports",
wantQuery: "id=42",
},
{
name: "non-empty path with query is unaffected",
basePath: "/api",
path: "/reports",
query: url.Values{"id": {"42"}},
wantPath: "/api/reports",
wantQuery: "id=42",
},
{
name: "query already in the path is preserved",
basePath: "/api/reports",
path: "?first=1",
query: url.Values{"second": {"2"}},
wantPath: "/api/reports",
wantQuery: "first=1&second=2",
},

// Resolution goes through net/url, so escaping and dot-segments
// follow the usual rules rather than whatever concatenation left.
{
name: "an escaped separator is not turned into a real one",
basePath: "/api",
path: "x%2Fy",
wantPath: "/api/x%2Fy",
},
{
name: "a space in a segment is escaped",
basePath: "/api",
path: "sp ace",
wantPath: "/api/sp%20ace",
},
{
name: "dot segments are resolved before sending",
basePath: "/api/reports",
path: "../other",
wantPath: "/api/other",
},
} {
t.Run(tc.name, func(t *testing.T) {
var gotPath, gotQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// EscapedPath, not Path: the difference between a literal
// separator and an escaped one is the point of two cases here.
gotPath = r.URL.EscapedPath()
gotQuery = r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

client := NewClient(server.URL + tc.basePath)
err := client.JSONRequest(context.Background(), Request{Method: "GET", Path: tc.path, Query: tc.query}, nil)
assert.NoError(t, err)
assert.Equal(t, tc.wantPath, gotPath)
assert.Equal(t, tc.wantQuery, gotQuery)
})
}
})
}

func TestClient_NoContentResponse(t *testing.T) {
Expand Down
Loading