Skip to content

fix(security): do not follow redirects off the validated origin (#22) - #26

Merged
karlwaldman merged 1 commit into
mainfrom
fix/no-redirect-following
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
fix/no-redirect-following

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #22.

The defect

src/Http/CurlTransport.php set CURLOPT_FOLLOWLOCATION => true. The #17
origin 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 — two
loopback servers, the first 302-ing to the second:

$client = new Client($key, 'http://127.0.0.1:18081', 5.0, 0);
$client->latest('BRENT_CRUDE_USD');

RETURNED code=PWNED price=1
capture log: {"uri":"/v1/prices/latest?by_code=BRENT_CRUDE_USD","host":"127.0.0.1:18082","authorization":null}

Fabricated data from a foreign origin, handed back as an authoritative price.
The authorization: null confirms the reviewer's note: libcurl 8.21.0 strips
Authorization across an origin change. It has only done so since 7.58.0
(the CVE-2018-1000007 hardening), and composer.json required
"ext-curl": "*" with no floor — so on an older host this was also key
disclosure.

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 wire
to 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:

$ curl -o /dev/null -w "%{http_code} num_redirects=%{num_redirects}\n" \
    https://api.oilpriceapi.com/v1/prices/latest?by_code=BRENT_CRUDE_USD
401 num_redirects=0
$ ... /v1/demo/prices
429 num_redirects=0

An intentional proxy is still reachable: point $baseUrl at the final URL.

What landed:

  • CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0.
  • A 3xx raises TransportException naming the Location, so a redirect
    surfaces 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_URL is still compared against the requested origin, as
    a tripwire. Under FOLLOWLOCATION => false it cannot fire today; it exists
    so 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.json declares "lib-curl": ">=7.58.0". ext-curl's own version
    is PHP's version (8.5.8 here), so it cannot express a libcurl floor —
    lib-curl is the honest constraint, and 7.58.0 is exactly where the
    cross-origin Authorization strip landed. composer validate --strict
    passes.

TDD evidence

Test written first, against unmodified origin/main source, and it drives the
real CurlTransport against real loopback servers — a mock transport
cannot see this defect, it lives inside cURL. New fixtures:
tests/fixtures/redirector.php and tests/fixtures/foreign-origin.php.

RED (./vendor/bin/phpunit --filter RedirectOriginTest, source at origin/main):

FFFFFF.F.FF                                                       11 / 11 (100%)

There were 9 failures:

1) RedirectOriginTest::testCrossOriginRedirectNeitherReturnsDataNorReachesTheForeignHost@301 moved permanently with data (301)
A 301 to a foreign origin returned price data: code=PWNED price=1

2) ...@302 found with data (302)
A 302 to a foreign origin returned price data: code=PWNED price=1

3) ...@303 see other with data (303)
A 303 to a foreign origin returned price data: code=PWNED price=1

4) ...@307 temporary redirect with data (307)
A 307 to a foreign origin returned price data: code=PWNED price=1

5) ...@308 permanent redirect with data (308)
A 308 to a foreign origin returned price data: code=PWNED price=1

6) RedirectOriginTest::testTheApiKeyNeverReachesTheRedirectTarget
The redirect target was contacted at all: {"uri":"/v1/prices/latest?by_code=BRENT_CRUDE_USD","host":"127.0.0.1:63342","authorization":null}

8) RedirectOriginTest::testRedirectRaisesAnErrorThatNamesTheRedirect
10) RedirectOriginTest::testTransportDoesNotEnableFollowLocation
11) RedirectOriginTest::testComposerDeclaresALibcurlFloor

FAILURES!
Tests: 11, Assertions: 52, Failures: 9.

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):

...........                                                       11 / 11 (100%)
OK (11 tests, 59 assertions)

Same standalone repro, now:

THREW: OilPriceAPI\Exception\TransportException: HTTP request to
http://127.0.0.1:18081/v1/prices/latest?by_code=BRENT_CRUDE_USD was answered
with a 302 redirect to http://127.0.0.1:18082/... This SDK does not follow
redirects, because a redirect moves the request - and the API key - to an
origin the client never validated.
capture log: (empty)

Full suite, PHP 8.5.8 / PHPUnit 13.3.3:

  • baseline on clean main @ 21339d43e: OK (108 tests, 478 assertions)
  • this branch: OK (119 tests, 537 assertions)

No pre-existing test changed.

Compatibility

Any caller pointing $baseUrl at a URL that redirects now gets a
TransportException instead of a silently-followed hop. Production does not
redirect, so this only affects a custom $baseUrl, and the message says
exactly what to do about it.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

`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
@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: d9611269-63a0-4f77-affd-982d3dfadb62


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 056669c into main Sep 13, 2026
8 checks passed
@karlwaldman
karlwaldman deleted the fix/no-redirect-following branch September 13, 2026 17:08
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
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>
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.

[P2] cURL redirect-following bypasses the #17 origin guard: a 302 returns foreign price data as authoritative

1 participant