Skip to content

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

Description

@karlwaldman

Confirmed live on main (3feb505). Verified by execution against the real cURL transport on PHP 8.5.8 / libcurl 8.21.0, 2026-09-13.

#17 validates the URL before the request and then hands it to cURL with CURLOPT_FOLLOWLOCATION => true (src/Http/CurlTransport.php:32-33). A 302 from the configured origin to any other host is followed silently, so the pre-flight origin check does not survive the first redirect and the SDK returns a price body sourced from a host that is not api.oilpriceapi.com.

Repro

Two loopback servers. Server A is the "API" the client is configured against and answers 302 Location: http://127.0.0.1:<B>/v1/prices. Server B is the foreign origin.

$client = new \OilPriceAPI\Client('fixture_key_NOT_REAL_0123456789', 'http://127.0.0.1:'.$portA, 5.0, 0);
echo json_encode($client->raw()->get('/v1/prices/latest'));

Result:

RESPONSE: {"status":"success","data":{"code":"PWNED","price":1}}
CAPTURED AT FOREIGN ORIGIN: {"uri":"/v1/prices","host":"127.0.0.1:57010","authorization":null,"ua":"oilpriceapi-php/2.1.2"}

The caller received a price row from the foreign host, with no error and no way to tell. Script: /private/tmp/redirlab/run.php (reproducible from the snippet above).

The credential itself is safe on a modern libcurl — with a caveat

Two controls, same harness:

  • No redirect (direct request to the capture server): authorization: "Token fixture_key_NOT_REAL_0123456789" — the harness does see auth headers, so the null above is a real strip, not a php -S artifact.
  • Same-origin redirect (/start → /v1/prices, same host:port): authorization: "Token fixture_key_NOT_REAL" — retained.

So libcurl 8.21.0 strips a CURLOPT_HTTPHEADER Authorization across an origin change. That is the CVE-2018-1000007 hardening, present since libcurl 7.58.0 (Jan 2018). composer.json:48 requires ext-curl: "*" with no version floor, so on a host built against an older libcurl (RHEL/CentOS 7-era, some shared hosting) the header would follow the redirect and this becomes a key-disclosure bug rather than a data-provenance bug.

Impact ranking

Data integrity first: the SDK's contract is source-timestamped data from OilPriceAPI, and today it will return whatever the last hop said. Credential exposure second, conditional on the host's libcurl.

Suggested fix

  • CURLOPT_FOLLOWLOCATION => false is the simplest correct answer — the OilPriceAPI base URL does not redirect, and a redirect from it is a signal, not a routine.
  • If redirects must stay, re-run Client::assertSameOrigin() against curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) after curl_exec and throw when it moved, and set CURLOPT_REDIR_PROTOCOLS_STR => 'https' so a downgrade to plaintext cannot happen either.
  • Independently: put a floor on ext-curl, or set CURLOPT_FOLLOWLOCATION => false so the floor stops mattering.

Also worth knowing while this stands: CURLOPT_HEADERFUNCTION accumulates headers across hops into one flat map (src/Http/CurlTransport.php:36-43), so a Retry-After or X-RateLimit-Limit present only on the first hop survives into the final response object.

Related: #14 (closed), #17. Found during the post-merge PHP expert review of #17/#18/#19.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Priority 2 - next sprintbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions