Skip to content

fix: resolveEndpoint path contract — spaces over-rejected, percent-encoded ".." under-rejected - #47

Merged
karlwaldman merged 1 commit into
mainfrom
fix/path-contract
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
fix/path-contract

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #40

The defect

resolveEndpoint had drifted in two directions at once. Both verified still reproducing on main (cd51e3a) before any change was written:

Path On main Should be
/v1/alerts/my alert id *InvalidPathError "contains a space or control character" escaped to %20 and sent (it worked before #36)
/v1/%2e%2e/%2e%2e/admin sent verbatim rejected
/v1/..%2fadmin sent verbatim rejected
/v1/../admin rejected rejected

Neither encoded form is a credential leak — both stay on the configured origin, which is what the origin guard is for. But a rule that refuses .. in one spelling and forwards it in another is not a rule, and a caller cannot reason about which paths the SDK will accept.

The fix

Over-rejection. The character scan refused every byte <= 0x20, which includes the space. It now refuses < 0x20 and DEL. CR/LF/NUL header injection is unaffected — those are still rejected, and covered by a test. A space cannot introduce an authority: the structural checks (starts with /, not //, no backslash) run on the raw string before anything is escaped, so " //fixture.invalid/v1/prices" still fails "must start with /". Spaces are escaped to %20 when the absolute URL is built.

Under-rejection. The .. segment check now runs on the raw path and on its percent-decoded form. One decoding pass, matching the single pass a server makes when it decodes a path; a malformed percent-escape (/v1/%zz) is refused rather than guessed at. Legitimate encoded data is untouched — /v1/prices/BRENT%2FWTI (an encoded slash inside a segment) and /v1/prices/BRENT%2eWTI both still resolve.

The origin guard is not weakened. sameOrigin, the structural checks and the leading-slash rule are unchanged; the only relaxation is one character in the control-character scan.

Proving the guard did not move

Relaxing an input filter is exactly the change that reopens an origin hole, so the guard is re-measured over generated forms rather than the hand-written probe list alone. TestResolveEndpointOriginCorpus builds leaders × schemes × authorities × tails — including the encodings this PR newly decodes and paths containing raw spaces — and asserts the single property that matters: every form is either rejected before a request is built, or resolves to the configured base origin.

=== RUN   TestResolveEndpointOriginCorpus
    path_origin_corpus_test.go:75: checked 61659 generated hostile path forms; every one was rejected or stayed on the base origin
--- PASS: TestResolveEndpointOriginCorpus (0.02s)

It passes against both pre-fix and post-fix code. That is the point — it measures the guard, not the change. (Run against unmodified origin/main client.go: ok github.com/OilpriceAPI/oilpriceapi-go 0.021s.)

The existing TestRawRejectsOffOriginPaths probe set is unchanged and still passes.

Red

Tests written first, run against unmodified origin/main client.go:

--- FAIL: TestResolveEndpointEscapesSpacesInPath (0.00s)
    path_contract_test.go:31: space in a path segment was rejected: invalid API path "/v1/alerts/my alert id": contains a space or control character (paths must be origin-relative and start with a single "/")
--- FAIL: TestResolveEndpointEscapesSpacesInQuery (0.00s)
    path_contract_test.go:43: space in a query value was rejected: invalid API path "/v1/prices/latest?by_code=BRENT CRUDE": contains a space or control character (paths must be origin-relative and start with a single "/")
--- FAIL: TestRawSendsSpacedPathEscapedToBaseOrigin (0.00s)
    path_contract_test.go:57: Raw with a spaced path returned invalid API path "/v1/alerts/my alert id": contains a space or control character (paths must be origin-relative and start with a single "/")
--- FAIL: TestResolveEndpointRejectsPercentEncodedTraversal (0.00s)
    --- FAIL: TestResolveEndpointRejectsPercentEncodedTraversal//v1/%2e%2e/%2e%2e/admin (0.00s)
        path_contract_test.go:90: percent-encoded traversal accepted: resolveEndpoint("/v1/%2e%2e/%2e%2e/admin") = "https://api.oilpriceapi.com/v1/%2e%2e/%2e%2e/admin"
    --- FAIL: TestResolveEndpointRejectsPercentEncodedTraversal//v1/%2E%2E/admin (0.00s)
        path_contract_test.go:90: percent-encoded traversal accepted: resolveEndpoint("/v1/%2E%2E/admin") = "https://api.oilpriceapi.com/v1/%2E%2E/admin"
    --- FAIL: TestResolveEndpointRejectsPercentEncodedTraversal//v1/..%2fadmin (0.00s)
        path_contract_test.go:90: percent-encoded traversal accepted: resolveEndpoint("/v1/..%2fadmin") = "https://api.oilpriceapi.com/v1/..%2fadmin"
    --- FAIL: TestResolveEndpointRejectsPercentEncodedTraversal//v1/..%2Fadmin (0.00s)
        path_contract_test.go:90: percent-encoded traversal accepted: resolveEndpoint("/v1/..%2Fadmin") = "https://api.oilpriceapi.com/v1/..%2Fadmin"
    --- FAIL: TestResolveEndpointRejectsPercentEncodedTraversal//v1/%2e%2e%2fadmin (0.00s)
        path_contract_test.go:90: percent-encoded traversal accepted: resolveEndpoint("/v1/%2e%2e%2fadmin") = "https://api.oilpriceapi.com/v1/%2e%2e%2fadmin"
FAIL
FAIL	github.com/OilpriceAPI/oilpriceapi-go	0.011s

Green

--- PASS: TestResolveEndpointEscapesSpacesInPath (0.00s)
--- PASS: TestResolveEndpointEscapesSpacesInQuery (0.00s)
--- PASS: TestRawSendsSpacedPathEscapedToBaseOrigin (0.00s)
--- PASS: TestResolveEndpointRejectsPercentEncodedTraversal (0.00s)
    --- PASS: TestResolveEndpointRejectsPercentEncodedTraversal//v1/%2e%2e/%2e%2e/admin (0.00s)
    --- PASS: TestResolveEndpointRejectsPercentEncodedTraversal//v1/%2E%2E/admin (0.00s)
    --- PASS: TestResolveEndpointRejectsPercentEncodedTraversal//v1/..%2fadmin (0.00s)
    --- PASS: TestResolveEndpointRejectsPercentEncodedTraversal//v1/..%2Fadmin (0.00s)
    --- PASS: TestResolveEndpointRejectsPercentEncodedTraversal//v1/%2e%2e%2fadmin (0.00s)
--- PASS: TestResolveEndpointRejectsMalformedPercentEscape (0.00s)
--- PASS: TestResolveEndpointStillRejectsControlCharacters (0.00s)
    --- PASS: TestResolveEndpointStillRejectsControlCharacters//v1/prices__X-Injected:_1 (0.00s)
    --- PASS: TestResolveEndpointStillRejectsControlCharacters//v1/prices_latest (0.00s)
    --- PASS: TestResolveEndpointStillRejectsControlCharacters//v1/prices_latest#01 (0.00s)
    --- PASS: TestResolveEndpointStillRejectsControlCharacters//v1/pricesNUL (0.00s)
    --- PASS: TestResolveEndpointStillRejectsControlCharacters//v1/prices\x7f (0.00s)
--- PASS: TestResolveEndpointStillRejectsSpacePaddedAuthority (0.00s)
    --- PASS: TestResolveEndpointStillRejectsSpacePaddedAuthority/__//fixture.invalid/v1/prices (0.00s)
    --- PASS: TestResolveEndpointStillRejectsSpacePaddedAuthority/_http://fixture.invalid/v1/prices (0.00s)
    --- PASS: TestResolveEndpointStillRejectsSpacePaddedAuthority//v1/prices_//fixture.invalid (0.00s)
--- PASS: TestResolveEndpointStillAllowsEncodedSlashInSegment (0.00s)
--- PASS: TestResolveEndpointStillAllowsEncodedDotsInSegment (0.00s)
    --- PASS: TestResolveEndpointStillAllowsEncodedDotsInSegment//v1/prices/BRENT%2eWTI (0.00s)
    --- PASS: TestResolveEndpointStillAllowsEncodedDotsInSegment//v1/prices/..wti (0.00s)
    --- PASS: TestResolveEndpointStillAllowsEncodedDotsInSegment//v1/prices/wti.. (0.00s)
PASS
ok  	github.com/OilpriceAPI/oilpriceapi-go	0.010s

Full suite

Check Result
go test ./... ok github.com/OilpriceAPI/oilpriceapi-go 16.518s — 311 pass / 0 fail (285 baseline + 26 new)
go test -race ./... ok github.com/OilpriceAPI/oilpriceapi-go 18.416s
go vet ./... clean
gofmt -l . clean

Known limit, stated rather than hidden

The decoded check is one pass. A double-encoded %252e%252e decodes once to %2e%2e, which is not a .. segment, and is forwarded. That matches what a server does when it decodes the path once, and looping to a fixed point would start rejecting legitimate double-encoded data. It remains same-origin either way.

Scope

No version bump, no tag, no release.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

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

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: 74c33686-7bf0-4d86-a6c0-6973fda33f6f


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 389fd3d into main Sep 13, 2026
9 checks passed
@karlwaldman
karlwaldman deleted the fix/path-contract branch September 13, 2026 19:05
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.

[P3] resolveEndpoint path contract: spaces over-rejected (regression), percent-encoded ".." under-rejected

1 participant