Repository navigation
fix(security): reject raw paths that change the authenticated API origin (#32) - #36
Merged
Merged
Conversation
The SDK attaches "Authorization: Token <key>" to every request in setHeaders, but doRequestWithHeaders built the URL by concatenating c.baseURL with an unchecked caller-supplied path. Any path not starting with "/" therefore appended to the base *host* rather than to its path, so the request — and the customer's API key — could be addressed elsewhere. Confirmed against main 3b24c6d with a capturing transport and a fixture key: "@fixture.invalid/v1/prices" -> host fixture.invalid ":8080@fixture.invalid/v1/prices" -> host fixture.invalid "@fixture.invalid:443/v1/prices" -> host fixture.invalid:443 "@[::1]:9/v1/prices" -> host [::1]:9 ".evil.invalid/v1/prices" -> host api.oilpriceapi.com.evil.invalid "v1/prices" -> host api.oilpriceapi.comv1 "http://fixture.invalid/v1/prices"-> host api.oilpriceapi.comhttp every one carrying "Token <key>". resolveEndpoint now runs before the request is built and before any credential is attached. It enforces the path contract the SDK already documented — a single leading "/", no authority, no backslash, no ".." segment, no whitespace or control characters — and then confirms the resolved (scheme, host, port) equals the configured base origin. That second check is an assertion rather than the primary defence, so a later change to the joining cannot silently reopen this. The gate sits in doRequestWithHeaders, so it covers Raw and every typed method that interpolates a caller-supplied id into a path (DeleteWebhook, alerts, webhooks) rather than Raw alone. Legitimate paths, percent-encoded segments, query parameters and explicit custom base URLs (proxies, test servers) are unchanged. Refs #32 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced 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 |
This was referenced Sep 13, 2026
Closed
[P2] #36 origin-pinning fix is merged but untagged — go get still installs the vulnerable v1.5.2
#39
Closed
Merged
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
Minor, not patch. #36 and #37 both changed behaviour callers can observe. Until this tag exists, `go get` installs v1.5.2 -- the version that sends `Authorization: Token <key>` to an attacker-controlled host. Merging #36 protected nobody. Breaking: * POST/PUT/PATCH/DELETE are no longer retried. CreateWebhook was observed sending four POSTs on a single 503. * A path without a leading `/` is rejected rather than concatenated. Raw("v1/prices") previously produced the host api.oilpriceapi.comv1. Version constant and CHANGELOG heading moved together, as release_contract_test.go requires. go test ./... ok (16.496s), gofmt and go vet clean. Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
…nts (#47) resolveEndpoint had drifted in two directions at once, and neither spelling matched the documented contract. Over-rejection (a regression from #36): the character scan refused every byte <= 0x20, which includes the space. "/v1/alerts/my alert id" used to work — it was escaped to %20 — and came back as *InvalidPathError instead. A space cannot introduce an authority: the structural checks run on the raw string, so a leading space still fails "must start with /". It only needs escaping. Under-rejection: the ".." segment check ran on the raw string only, so "/v1/%2e%2e/%2e%2e/admin" and "/v1/..%2fadmin" passed it and went out verbatim while "/v1/../admin" was refused. Both stay on the configured origin, so this is a contract fix rather than a leak fix — but a rule that depends on the spelling is not a rule. Changes: - Control characters (< 0x20 and DEL) stay rejected; the space does not. CR/LF/NUL header injection is unaffected. - The ".." segment check runs on the raw path and again on its percent-decoded form. One decoding pass, matching the single pass a server makes. A malformed percent-escape is refused rather than guessed at. - Spaces are escaped to %20 when the absolute URL is built. The origin guard itself is untouched. A generated corpus of 61,659 hostile path forms (leaders x schemes x authorities x tails, including the encodings this PR newly decodes) is added as a test: every form is either rejected at the boundary or resolves to the configured base origin. It passes against both pre-fix and post-fix code, which is the point — it measures the guard, not the change. Closes #40 Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #32.
Confirmed live on
main3b24c6d— and worse than the issue textThe issue predicted
@fixture.invalid/v1/pricesresolves off-origin. It does, and so do five other forms. The SDK attachesAuthorization: Token <key>insetHeadersunconditionally, anddoRequestWithHeadersbuilt the URL asc.baseURL + endpointwith no check — so any path not starting with/appends to the base host, not to its path, and the customer's key goes with it.Probed with a capturing
http.RoundTripperand the placeholder keyFIXTURE-KEY-NOT-A-REAL-CREDENTIAL. No network request and no real credential was used.Where Go differs from the Python fix (python-sdk#113)
Do not assume the Python findings port. They do not:
main//fixture.invalid/v1/pricesresolves to a foreign hosturljoins, so//hostis only a double-slash path onapi.oilpriceapi.com.//user@fixture.invalid/...swapsTokenforBasic dXNlcjo=setHeadersruns afterhttp.NewRequest, so it would overwrite any userinfo-derived Basic header anyway..evil.invalid/v1/pricesproducesapi.oilpriceapi.com.evil.invalid, a fully attacker-controlled domain. Python'surljoinnever had this shape.Bypass-probe results — full set, before and after
18 adversarial forms plus 6 legitimate paths.
hostis what the transport actually saw;authis the header that rode along.//fixture.invalid/v1/prices@fixture.invalid/v1/prices:8080@fixture.invalid/v1/prices@fixture.invalid:443/v1/prices@[::1]:9/v1/prices//user@fixture.invalid/v1/prices.evil.invalid/v1/pricesv1/prices\\fixture.invalid/v1/prices*url.Error*InvalidPathError\\@fixture.invalid/v1/prices*url.Error*InvalidPathError%2F%2Ffixture.invalid/v1/prices*url.Error*InvalidPathErrorHTTP://fixture.invalid/v1/priceshttp://fixture.invalid/v1/priceshttp://api.oilpriceapi.com/v1/prices//fixture.invalid/v1/prices*url.Error*InvalidPathError/..//fixture.invalid/v1/prices/../../v1/prices/v1/prices/latest/v1/prices/latest?by_code=BRENT_CRUDE_USD/v1/pri%20ces/latest/v1/prices/BRENT%2FWTI%2Fnot decoded to//@fixture.invalid/v1/prices@inside a path is fine)/v1/well-production/wells/42/monthlySix forms reached a foreign or attacker-suffixed host with a live
Authorization: Tokenheader. Three were already rejected, but bynet/urlwith an untyped*url.Errorthat callers cannot distinguish from a transport failure; they now return*InvalidPathError. Nine more stayed same-origin but are refused at the boundary rather than relied on — Go's parser quirks are not a security contract.The fix
resolveEndpointinclient.go, called at the top ofdoRequestWithHeadersbefore the request is built and before any credential is attached:?or#— an authority can only appear there./; reject//, any backslash, and any..segment.(scheme, host, port)equals the base origin, folding default ports.Step 4 cannot fail given steps 1–3. It is deliberately kept as the assertion that survives a future refactor of the joining, which is exactly how this bug arose.
The gate is in
doRequestWithHeaders, not inRaw.Rawis not the only entry point:DeleteWebhook, the alerts methods and the webhooks methods interpolate a caller-supplied id straight into a path ("/v1/alerts/" + id) with no escaping. Those now go through the same check — verified inTestTypedEndpointsRejectOffOriginIDs.Explicit custom base URLs (proxies, test servers, on-prem) still define the allowed origin and keep working, including with a trailing slash.
Red / green
Red, with
client.goreverted toorigin/mainand the new test file in place:Green, with the fix restored:
Note
TestCustomBaseURLStillWorksdrops from 7.03s to 0.00s — pre-fix, its off-origin probe performed a real DNS lookup forfixture.invalid.Full suite
origin/maingo test ./...ok ... 1.314sok ... 1.318sgo vet ./...clean,gofmt -l .clean. Go 1.26.5 darwin/arm64.Reviewer notes
/. Nothing in the SDK does, andRaw's doc comment has always required/, but it is a minor-version change, not a patch...-segment rejection is stricter than strictly needed for origin safety (/../../v1/pricesstays same-origin onmain). It is included because an intermediary that normalises/..//host/xto//host/xre-creates the authority form, and no legitimate OilPriceAPI path contains... Say the word if you want that relaxed.release_contract_test.gorequires the top changelog heading to equalVersion, so the entry has to land with the version bump at release time.🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo