fix(remote): resolve request URLs with net/url so an empty path keeps no trailing separator - #144
fix(remote): resolve request URLs with net/url so an empty path keeps no trailing separator#144Aravinda-HWK wants to merge 2 commits into
Conversation
|
Important Review available on request
Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ginaxu1
left a comment
There was a problem hiding this comment.
what happens if a caller issues a request with an empty or root path AND query parameters (req.Query)? add a unit test for that
@ginaxu1 I fixed this issue and added the unit test cases as well. |
…path Joining the base URL and the request path unconditionally inserted a "/", so an empty path produced "<base>/" instead of "<base>". A trailing slash addresses a different resource than the bare path, and some servers reject the trailing form outright — the IPPC ePhyto Hub answers it with 405. An empty (or "/") path means the caller is addressing the service URL itself, so send the base URL verbatim. Non-empty paths join as before, still collapsing a duplicated separator.
… joins
The previous fix concatenated the base URL and path by hand, which left two
defects. A request carrying query parameters reached the join with the query
already appended to the path (JSONRequest does that before calling), so an
empty path arrived as "?id=42" — not empty, so the trailing separator came
back and the 405 the fix targeted returned for every call with a query. Path
segments were also passed through unescaped.
Resolve with net/url instead: parse the base and the request path, join the
escaped path with URL.JoinPath, and carry query and fragment on the URL
rather than splicing them into a string. Query merges when both the base URL
and the request supply one.
EscapedPath is joined rather than Path, since joining the decoded form would
turn an escaped separator ("x%2Fy") into a real one and address a different
resource than the caller asked for.
One rule stays explicit: a root-only path resolves to the base URL itself.
JoinPath preserves a trailing separator by design, so "/" would otherwise
produce "<base>/" — a different resource that some servers reject, and a
caller addressing the service URL itself has no other way to say so.
8362b1a to
9430b00
Compare
sthanikan2000
left a comment
There was a problem hiding this comment.
Verified the fix for the reported trailing-separator bug and the SSRF guard — both hold up. Found one confirmed regression that should block merge (inline below), plus one minor efficiency note.
| // 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) |
There was a problem hiding this comment.
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.
| return path, nil | ||
| } | ||
|
|
||
| base, err := url.Parse(c.baseURL) |
There was a problem hiding this comment.
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.
Summary
Fixes the URL the
remoteclient sends when the request path is empty or root, and replaces the hand-rolled join that caused it withnet/urlresolution.The problem
Client.executeOncejoined the base URL and the request path by concatenation, always inserting a separator:With an empty path that yields
<base>/./api/reportsand/api/reports/are different resources as far as HTTP is concerned, and some servers answer the trailing form with 405 Method Not Allowed — so a caller configured with the full service URL as its base, addressing that URL itself, could not reach the service.The same concatenation carried two more defects, both surfaced during review:
JSONRequestappends the encodedQueryto the path before the join runs, so an empty path arrives as"?id=42"— not empty. Any empty-path special case never fires and the trailing slash returns for every call carrying a query.The bug only appears when the base URL itself has a path: with a bare origin,
net/httpnormalises an empty path to/on the wire either way. Every pre-existing test used a path-lesshttptestbase URL, which is why none of them caught it.Closes #146.
The solution
Resolution moved into a
Client.resolveURLmethod built onnet/url, so no part of the URL is assembled with string arithmetic:Why this shape:
url.Parseputs it inRawQuerywhere it belongs, so the empty-path decision looks at the path alone. That removes defect (1) structurally rather than by special-casing?.EscapedPath(), notPath. Joining the decoded form would turn an escaped separator (x%2Fy) into a real one, addressing a different resource than the caller asked for. This is the trap in the obvious version of this refactor, and there is a test pinning it.ResolveReferenceis not used, despite being the usual answer for URL joining. It follows RFC 3986 relative-reference semantics and replaces the last path segment:"send"against…/api/reportsyields…/api/send, silently droppingreports.One rule stays explicit, because no library can decide it: a root-only path resolves to the base URL itself.
JoinPathpreserves a trailing separator by design, so"/"would otherwise reproduce the original bug. Whether/means "the service URL" or "the collection under it" is a decision about this client's API, and it is commented as such.Beyond the reported bug
Two behaviours the rewrite fixes for free, both covered by tests:
"sp ace"sp%20ace"../other"…/reports/../othersent literally/api/otherType of Change
Changes Made
remote/client.go— extracts URL resolution fromexecuteOnceintoClient.resolveURL, rewritten onnet/url. The absolute-path branch keeps its existing scheme/host check against the configured service (the SSRF guard) and is unchanged in behaviour; the relative branch is the rewrite.remote/client_test.go— the table below.Testing
Ten table-driven cases under
TestClient_BaseURL_Logic, assertingr.URL.EscapedPath()andr.URL.RawQueryas observed by anhttptestserver rather than re-deriving the join in the test:PathQuery""/api/reports"/"/api/reportsreports/api/reports/reports/api/reports""id=42/api/reportsid=42"/"id=42/api/reportsid=42/reportsid=42/api/reportsid=42?first=1second=2/api/reportsfirst=1&second=2x%2Fy/api/x%2Fysp ace/api/sp%20ace../other/api/otherThe query cases were written before the fix and observed to fail (
expected "/api/reports", actual "/api/reports/"), so they demonstrate the defect rather than merely describing it.go build ./...,go vet ./...andgo test -race ./...all pass in theremotemodule, including the mTLS and manager suites, which exerciseresolveURLthroughRawRequest.Checklist
Docs unticked: no user-facing documentation describes the join behaviour, and the reasoning lives in comments at the decision points.
Related Issues
Closes #146
Additional Notes
Lint not verified locally. The installed
golangci-lintv2.11.2 is built against Go 1.25 and refuses the repo'sgo 1.26target, so the lint gate is left to CI.Behaviour changes worth naming:
""where it was"/". Both are unusable and both fail; the error moves from the transport intohttp.NewRequestWithContext.