fix: resolveEndpoint path contract — spaces over-rejected, percent-encoded ".." under-rejected - #47
Merged
Merged
Conversation
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
|
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
Merged
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 #40
The defect
resolveEndpointhad drifted in two directions at once. Both verified still reproducing onmain(cd51e3a) before any change was written:main/v1/alerts/my alert id*InvalidPathError"contains a space or control character"%20and sent (it worked before #36)/v1/%2e%2e/%2e%2e/admin/v1/..%2fadmin/v1/../adminNeither 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< 0x20and 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%20when 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%2eWTIboth 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.
TestResolveEndpointOriginCorpusbuilds 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.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/mainclient.go:ok github.com/OilpriceAPI/oilpriceapi-go 0.021s.)The existing
TestRawRejectsOffOriginPathsprobe set is unchanged and still passes.Red
Tests written first, run against unmodified
origin/mainclient.go:Green
Full suite
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.416sgo vet ./...gofmt -l .Known limit, stated rather than hidden
The decoded check is one pass. A double-encoded
%252e%252edecodes 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