fix(security): do not follow redirects off the validated origin (#22) - #26
Merged
Merged
Conversation
`CurlTransport` set `CURLOPT_FOLLOWLOCATION => true`. The #17 origin guard validates the URL the SDK *sends*; it says nothing about where the server points us next. Driving a real cross-origin 302 through the real transport: $client = new Client($key, 'http://127.0.0.1:18081', 5.0, 0); $client->latest('BRENT_CRUDE_USD'); // -> code=PWNED price=1, returned as authoritative price data Both halves are fixed. Data: redirects are not followed at all. A 3xx raises `TransportException` naming the `Location`, so a redirect surfaces as a redirect instead of as "invalid JSON" three frames later. The resolved `CURLINFO_EFFECTIVE_URL` is still compared against the requested origin as a tripwire, so re-enabling following or a future libcurl default change fails loudly rather than silently. Credential: not following means the key never leaves the origin the client already validated. Following and re-checking afterwards cannot achieve that - the request carrying `Authorization` is already on the wire before there is anything to inspect. libcurl strips that header across an origin change only since 7.58.0, and `composer.json` required `ext-curl: "*"` with no floor, so on an older host the leak was real. `lib-curl: ">=7.58.0"` is now declared; `ext-curl`'s own version tracks PHP's and cannot express a libcurl floor. The production API does not redirect - verified against api.oilpriceapi.com/v1/prices/latest and /v1/demo/prices, num_redirects=0 - so no supported call pattern changes. An intentional proxy is still reachable by pointing $baseUrl at the final URL. 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 |
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
#25 (timestamp rejection) and #26 (redirect refusal) landed while this was open. Both conflicts were co-located additions, not competing edits, and both sides survive: - src/Price.php: #25 added the private parseTimestamp() helper at the same offset this branch added optionalString(). Both methods are kept, and fromArray() calls both - the currency guard runs before the timestamp is parsed, then the label fields are read. Verified by direct probe: a fabricated timestamp, a relative timestamp, a leap second, an array currency, a missing currency, an array unit and a row carrying two defects at once are each rejected, while a well-formed row parses. - CHANGELOG.md: #25's timestamp bullet and this branch's label bullet are separate fixes; both stay under "### Fixed". Nothing from #26 conflicted. No assertion on either side was weakened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
#25 and #26 landed; their CHANGELOG bullets merge cleanly under the 3.0.0 heading this branch created. No conflict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
This was referenced Sep 13, 2026
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
) Adds phpstan/phpstan as a dev dependency, a phpstan.neon at level 9 over src/ with no baseline, a "phpstan" composer script, and a static-analysis job in the test workflow. The 17 errors quoted in #24 are stale: PR #27 removed six Price.php casts and #26 rewrote the transport. Re-measured on 0672df0, level 9 on src/ reports 14. All 14 are fixed here, so the level ships clean: - dataOrFail()/priceOrFail() rebuild decoded JSON objects with string keys, making the array<string, mixed> the SDK declares everywhere actually true (json_decode turns a numeric JSON key into an int array key). - originOf() in Client and CurlTransport reads parse_url() parts through is_string()/is_int() instead of casting mixed, so an unnameable part cannot compare equal to a real origin. - errorMessage() narrows $body['error'] and $body['data'] to arrays before reaching into them. - CurlTransport rejects an empty method or URL, which is also what curl_setopt_array's non-empty-string contract requires. - Removed two provably dead checks: the api-key null test the guard above it already proved, and the assert()/null seed on a loop that always runs. No baseline file and no @PHPStan-Ignore comments. phpstan.neon is export-ignored and excluded from the composer archive - it is dev tooling, not package content. The PHP constraint stays >=8.1: it is pinned by an existing assertion in PublicClaimsTest and narrowing it to ^8.1 is a packaging decision with its own release note, not a CI change. 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 #22.
The defect
src/Http/CurlTransport.phpsetCURLOPT_FOLLOWLOCATION => true. The #17origin guard validates the URL the SDK sends; it has nothing to say about
where the server points us next.
Reproduced on
origin/main@21339d43e, PHP 8.5.8, libcurl 8.21.0 — twoloopback servers, the first 302-ing to the second:
Fabricated data from a foreign origin, handed back as an authoritative price.
The
authorization: nullconfirms the reviewer's note: libcurl 8.21.0 stripsAuthorizationacross an origin change. It has only done so since 7.58.0(the CVE-2018-1000007 hardening), and
composer.jsonrequired"ext-curl": "*"with no floor — so on an older host this was also keydisclosure.
The approach I chose, and why
Do not follow redirects. Not "follow and re-validate the effective URL".
Re-validating afterwards fixes the data half but cannot fix the credential
half: the request carrying
Authorization: Token <key>is already on the wireto the new host before there is any effective URL to inspect. On a host with
libcurl < 7.58.0 that is the leak, and the SDK has no way to detect the
version gap at the moment it matters. Not following is the only option that
keeps the key inside the origin the client already validated.
It also costs nothing. Production does not redirect — verified just now:
An intentional proxy is still reachable: point
$baseUrlat the final URL.What landed:
CURLOPT_FOLLOWLOCATION => false,CURLOPT_MAXREDIRS => 0.TransportExceptionnaming theLocation, so a redirectsurfaces as a redirect rather than as "API returned invalid JSON" three
frames later. That matters: the old path would have sent an integrator
hunting a JSON bug.
CURLINFO_EFFECTIVE_URLis still compared against the requested origin, asa tripwire. Under
FOLLOWLOCATION => falseit cannot fire today; it existsso that re-enabling following, or a future libcurl default change, fails
loudly instead of silently. A source-level regression test fences the
setting itself.
composer.jsondeclares"lib-curl": ">=7.58.0".ext-curl's own versionis PHP's version (
8.5.8here), so it cannot express a libcurl floor —lib-curlis the honest constraint, and 7.58.0 is exactly where thecross-origin
Authorizationstrip landed.composer validate --strictpasses.
TDD evidence
Test written first, against unmodified
origin/mainsource, and it drives thereal
CurlTransportagainst real loopback servers — a mock transportcannot see this defect, it lives inside cURL. New fixtures:
tests/fixtures/redirector.phpandtests/fixtures/foreign-origin.php.RED (
./vendor/bin/phpunit --filter RedirectOriginTest, source atorigin/main):Re-proved red-capable after the fix by
git checkout origin/main -- src/Http/CurlTransport.php composer.json:Tests: 11, Assertions: 52, Failures: 9.— then restored.GREEN (this branch):
Same standalone repro, now:
Full suite, PHP 8.5.8 / PHPUnit 13.3.3:
main@21339d43e:OK (108 tests, 478 assertions)OK (119 tests, 537 assertions)No pre-existing test changed.
Compatibility
Any caller pointing
$baseUrlat a URL that redirects now gets aTransportExceptioninstead of a silently-followed hop. Production does notredirect, so this only affects a custom
$baseUrl, and the message saysexactly what to do about it.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo