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.
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 notapi.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.Result:
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:
authorization: "Token fixture_key_NOT_REAL_0123456789"— the harness does see auth headers, so thenullabove is a real strip, not aphp -Sartifact./start→/v1/prices, same host:port):authorization: "Token fixture_key_NOT_REAL"— retained.So libcurl 8.21.0 strips a
CURLOPT_HTTPHEADERAuthorizationacross an origin change. That is the CVE-2018-1000007 hardening, present since libcurl 7.58.0 (Jan 2018).composer.json:48requiresext-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 => falseis the simplest correct answer — the OilPriceAPI base URL does not redirect, and a redirect from it is a signal, not a routine.Client::assertSameOrigin()againstcurl_getinfo($ch, CURLINFO_EFFECTIVE_URL)aftercurl_execand throw when it moved, and setCURLOPT_REDIR_PROTOCOLS_STR => 'https'so a downgrade to plaintext cannot happen either.ext-curl, or setCURLOPT_FOLLOWLOCATION => falseso the floor stops mattering.Also worth knowing while this stands:
CURLOPT_HEADERFUNCTIONaccumulates headers across hops into one flat map (src/Http/CurlTransport.php:36-43), so aRetry-AfterorX-RateLimit-Limitpresent 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.