-
Notifications
You must be signed in to change notification settings - Fork 5
fix(remote): resolve request URLs with net/url so an empty path keeps no trailing separator #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested fix: parse |
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
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.baseURLon 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:
baseURLis immutable afterNewClient(noOptionin options.go touches it), andurl.URL.JoinPathnever mutates its receiver, so a single parsed*url.URLcan safely be cached onClientand reused across concurrent requests with no extra locking. One wrinkle:NewClienthas no error return today, so this isn't quite "fail fast at construction" — the practical version is to parse once, store both the*url.URLand any parse error, and return the stored error fromresolveURLon first use, same as today's behavior but without re-parsing every call.