Skip to content

fix(security): reject raw paths that change the authenticated API origin (#32) - #36

Merged
karlwaldman merged 1 commit into
mainfrom
fix/32-reject-off-origin-raw-paths
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
fix/32-reject-off-origin-raw-paths

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #32.

Confirmed live on main 3b24c6d — and worse than the issue text

The issue predicted @fixture.invalid/v1/prices resolves off-origin. It does, and so do five other forms. The SDK attaches Authorization: Token <key> in setHeaders unconditionally, and doRequestWithHeaders built the URL as c.baseURL + endpoint with 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.RoundTripper and the placeholder key FIXTURE-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:

Python finding Go behaviour on main
//fixture.invalid/v1/prices resolves to a foreign host Does not reproduce. Go concatenates rather than urljoins, so //host is only a double-slash path on api.oilpriceapi.com.
//user@fixture.invalid/... swaps Token for Basic dXNlcjo= Does not reproduce. Stays same-origin, and setHeaders runs after http.NewRequest, so it would overwrite any userinfo-derived Basic header anyway.
— New in Go: the host-suffix form .evil.invalid/v1/prices produces api.oilpriceapi.com.evil.invalid, a fully attacker-controlled domain. Python's urljoin never had this shape.

Bypass-probe results — full set, before and after

18 adversarial forms plus 6 legitimate paths. host is what the transport actually saw; auth is the header that rode along.

# probe path before (host) auth attached after
1 //fixture.invalid/v1/prices api.oilpriceapi.com yes rejected
2 @fixture.invalid/v1/prices fixture.invalid yes rejected
3 :8080@fixture.invalid/v1/prices fixture.invalid yes rejected
4 @fixture.invalid:443/v1/prices fixture.invalid:443 yes rejected
5 @[::1]:9/v1/prices [::1]:9 yes rejected
6 //user@fixture.invalid/v1/prices api.oilpriceapi.com yes rejected
7 .evil.invalid/v1/prices api.oilpriceapi.com.evil.invalid yes rejected
8 v1/prices api.oilpriceapi.comv1 yes rejected
9 \\fixture.invalid/v1/prices none — *url.Error no rejected, *InvalidPathError
10 \\@fixture.invalid/v1/prices none — *url.Error no rejected, *InvalidPathError
11 %2F%2Ffixture.invalid/v1/prices none — *url.Error no rejected, *InvalidPathError
12 HTTP://fixture.invalid/v1/prices api.oilpriceapi.comHTTP yes rejected
13 http://fixture.invalid/v1/prices api.oilpriceapi.comhttp yes rejected
14 http://api.oilpriceapi.com/v1/prices api.oilpriceapi.comhttp yes rejected
15 //fixture.invalid/v1/prices none — *url.Error no rejected, *InvalidPathError
16 /..//fixture.invalid/v1/prices api.oilpriceapi.com yes rejected
17 /../../v1/prices api.oilpriceapi.com yes rejected
18 `` (empty) api.oilpriceapi.com yes rejected
L1 /v1/prices/latest api.oilpriceapi.com yes unchanged, allowed
L2 /v1/prices/latest?by_code=BRENT_CRUDE_USD api.oilpriceapi.com yes unchanged, allowed
L3 /v1/pri%20ces/latest api.oilpriceapi.com yes unchanged, allowed, escaping preserved
L4 /v1/prices/BRENT%2FWTI api.oilpriceapi.com yes unchanged, allowed, %2F not decoded to /
L5 /@fixture.invalid/v1/prices api.oilpriceapi.com yes unchanged, allowed (an @ inside a path is fine)
L6 /v1/well-production/wells/42/monthly api.oilpriceapi.com yes unchanged, allowed

Six forms reached a foreign or attacker-suffixed host with a live Authorization: Token header. Three were already rejected, but by net/url with an untyped *url.Error that 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

resolveEndpoint in client.go, called at the top of doRequestWithHeaders before the request is built and before any credential is attached:

  1. Reject whitespace and control characters.
  2. Take the portion before the first ? or # — an authority can only appear there.
  3. Require a single leading /; reject //, any backslash, and any .. segment.
  4. Parse the base URL (trailing slashes trimmed), join, re-parse, and assert the resolved (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 in Raw. Raw is 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 in TestTypedEndpointsRejectOffOriginIDs.

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.go reverted to origin/main and the new test file in place:

$ git checkout origin/main -- client.go && go test -run TestRawRejectsOffOriginPaths -v .
    raw_origin_test.go:74: off-origin request: path "@fixture.invalid/v1/prices" reached host "fixture.invalid" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:74: off-origin request: path ":8080@fixture.invalid/v1/prices" reached host "fixture.invalid" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:74: off-origin request: path "@fixture.invalid:443/v1/prices" reached host "fixture.invalid:443" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:74: off-origin request: path "@[::1]:9/v1/prices" reached host "[::1]:9" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:74: off-origin request: path ".evil.invalid/v1/prices" reached host "api.oilpriceapi.com.evil.invalid" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:74: off-origin request: path "v1/prices" reached host "api.oilpriceapi.comv1" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:88: path "\\\\fixture.invalid/v1/prices" rejected with *url.Error (...), want *InvalidPathError
    raw_origin_test.go:88: path "\\\\@fixture.invalid/v1/prices" rejected with *url.Error (...), want *InvalidPathError
    raw_origin_test.go:88: path "%2F%2Ffixture.invalid/v1/prices" rejected with *url.Error (...), want *InvalidPathError
    raw_origin_test.go:74: off-origin request: path "HTTP://fixture.invalid/v1/prices" reached host "api.oilpriceapi.comHTTP" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:74: off-origin request: path "http://fixture.invalid/v1/prices" reached host "api.oilpriceapi.comhttp" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:74: off-origin request: path "http://api.oilpriceapi.com/v1/prices" reached host "api.oilpriceapi.comhttp" carrying "Token FIXTURE-KEY-NOT-A-REAL-CREDENTIAL"
    raw_origin_test.go:88: path "  //fixture.invalid/v1/prices" rejected with *url.Error (...), want *InvalidPathError
--- FAIL: TestRawRejectsOffOriginPaths (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/userinfo-embedded (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/userinfo-with-port (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/userinfo-host-port (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/userinfo-ipv6 (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/host-suffix (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/no-leading-slash (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/backslash-authority (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/backslash-userinfo (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/percent-encoded-slashes (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/uppercase-scheme (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/absolute-url (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/scheme-downgrade-own-host (0.00s)
    --- FAIL: TestRawRejectsOffOriginPaths/whitespace-padded (0.00s)
FAIL	github.com/OilpriceAPI/oilpriceapi-go	0.010s

Green, with the fix restored:

$ go test -run 'TestRawRejectsOffOriginPaths|TestRawAllowsLegitimatePaths|TestCustomBaseURLStillWorks|TestTypedEndpointsRejectOffOriginIDs' -v .
--- PASS: TestRawRejectsOffOriginPaths (0.00s)        [18/18 subtests PASS]
--- PASS: TestRawAllowsLegitimatePaths (0.00s)        [6/6 subtests PASS]
--- PASS: TestCustomBaseURLStillWorks (0.00s)
--- PASS: TestTypedEndpointsRejectOffOriginIDs (0.00s)
    raw_origin_test.go:181: DeleteWebhook rejected: invalid API path "/v1/webhooks/../../../@fixture.invalid/x": path contains a ".." segment (paths must be origin-relative and start with a single "/")
ok  	github.com/OilpriceAPI/oilpriceapi-go	0.011s

Note TestCustomBaseURLStillWorks drops from 7.03s to 0.00s — pre-fix, its off-origin probe performed a real DNS lookup for fixture.invalid.

Full suite

baseline origin/main this branch
go test ./... ok ... 1.314s ok ... 1.318s
passing tests 237 265 (+28)
failing tests 0 0

go vet ./... clean, gofmt -l . clean. Go 1.26.5 darwin/arm64.

Reviewer notes

  • This is behaviour-breaking for a caller who relied on a path without a leading /. Nothing in the SDK does, and Raw's doc comment has always required /, but it is a minor-version change, not a patch.
  • The ..-segment rejection is stricter than strictly needed for origin safety (/../../v1/prices stays same-origin on main). It is included because an intermediary that normalises /..//host/x to //host/x re-creates the authority form, and no legitimate OilPriceAPI path contains ... Say the word if you want that relaxed.
  • No CHANGELOG entry: release_contract_test.go requires the top changelog heading to equal Version, so the entry has to land with the version bump at release time.
  • Not merged, no auto-merge, no release tagged.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

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
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d4066192-aff8-4ee2-af0b-24dbcc3651dc


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@karlwaldman
karlwaldman merged commit 6ceca9b into main Sep 13, 2026
9 checks passed
@karlwaldman
karlwaldman deleted the fix/32-reject-off-origin-raw-paths branch September 13, 2026 16:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1][Review] Reject raw paths that change the authenticated API origin

1 participant