Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,68 @@ on:
branches:
- master

permissions:
contents: read

jobs:
test:
name: PHP ${{ matrix.php-version }} / ${{ matrix.dependencies }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
php-version: ['8.1', '8.2', '8.3']
php-version: ['8.1', '8.2', '8.3', '8.4', '8.5']
dependencies: [latest]
include:
- php-version: '8.1'
dependencies: lowest
- php-version: '8.2'
minimum-patched-version: '8.2.35'
- php-version: '8.3'
minimum-patched-version: '8.3.36'
- php-version: '8.4'
minimum-patched-version: '8.4.27'
- php-version: '8.5'
minimum-patched-version: '8.5.12'

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- name: Set up PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2
with:
php-version: ${{ matrix.php-version }}
extensions: curl
extensions: curl, openssl, zlib
coverage: none
tools: composer:v2

- name: Verify supported runtimes include the security patches
if: matrix.minimum-patched-version != ''
env:
MINIMUM_PATCHED_VERSION: ${{ matrix.minimum-patched-version }}
run: |
php -r 'exit(version_compare(PHP_VERSION, getenv("MINIMUM_PATCHED_VERSION"), ">=") ? 0 : 1);'

- name: Validate Composer
run: composer validate
run: composer validate --strict

- name: Install dependencies
run: composer install --prefer-dist --no-interaction
run: |
if [ "${{ matrix.dependencies }}" = "lowest" ]; then
composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction --no-progress
else
composer install --prefer-dist --no-interaction --no-progress
fi

- name: Audit all resolved dependencies
run: composer audit --locked --format=json

- name: Check PHP syntax
run: find lib tests -name '*.php' -print0 | xargs -0 -n1 php -l

- name: Run unit tests
run: ./vendor/bin/phpunit --exclude-group live
run: composer test:unit
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 3.0.2

- Enforce connection and read limits with cURL, reject truncated responses, and bound decoded response bodies, including gzip.
- Validate HTTP headers and user-agent values before sending requests, including through custom transports.
- Redact API keys from configuration debug output and transport diagnostics, and close response streams after use.
- Report timeouts without incorrectly identifying the connection phase or duration.
- Add local HTTP/TLS regression coverage and CI for PHP 8.1 through 8.5, including the lowest allowed dependencies on PHP 8.1.
- Keep public methods, response models, JSON/XML support and PHP 8.1 syntax compatibility. Custom Guzzle exception subclasses with incompatible constructors are normalized to the corresponding base exception, and nested diagnostic causes are redacted copies.

## 3.0.1

- Raised the minimum `guzzlehttp/guzzle` version to 7.15.2 and the dev `phpunit/phpunit` version to 10.5.62, so installs can no longer resolve releases with known security vulnerabilities. No API or behavior changes.
Expand Down
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Look up IPv4, IPv6, and domains with `/v3/ipgeo` and `/v3/ipgeo-bulk`. Get geolo
## Table of Contents

- [Installation](#installation)
- [Runtime Security](#runtime-security)
- [Quick Start](#quick-start)
- [At a Glance](#at-a-glance)
- [Get Your API Key](#get-your-api-key)
Expand All @@ -25,6 +26,7 @@ Look up IPv4, IPv6, and domains with `/v3/ipgeo` and `/v3/ipgeo-bulk`. Get geolo
- [Errors](#errors)
- [Troubleshooting](#troubleshooting)
- [Frequently Asked Questions](#frequently-asked-questions)
- [Development and Testing](#development-and-testing)
- [Links](#links)

## Installation
Expand All @@ -45,6 +47,51 @@ use Ipgeolocation\Sdk\IpGeolocationClient;
- Package page: <https://packagist.org/packages/ipgeolocation/ipgeolocation-php-sdk>
- GitHub repository: <https://github.com/IPGeolocation/ip-geolocation-api-php>

## Runtime Security

PHP 8.1 is the SDK's language compatibility minimum. Production applications
must use a security-patched PHP runtime. The SDK cannot repair PHP engine
vulnerabilities through a Composer update.

The [21 October 2026 PHP advisory](https://www.php.net/archive/2026.php#2026-10-21-5)
lists these minimum upstream fixes for CVE-2026-109618 and CVE-2026-109619:

| PHP branch | Minimum patched version |
| --- | --- |
| 8.2 | 8.2.35 |
| 8.3 | 8.3.36 |
| 8.4 | 8.4.27 |
| 8.5 | 8.5.12 |

Install the latest security release in a supported branch, or verify that your
distribution/vendor package includes the fixes. PHP 8.1 is upstream end of life;
its CI jobs check SDK compatibility and do not certify the runtime as secure.
Legacy deployments need verified vendor backports or an upgrade to supported PHP.

Update the packages used by the web server and restart the PHP workers. Verify
the package revision used by PHP-FPM, Apache or your other web SAPI; the CLI can
use a different PHP installation.

Also review the [23 October session-upload advisory](https://www.php.net/archive/2026.php#2026-10-23-1)
for CVE-2026-115560. It concerns applications that configure the session serializer
as `php_binary` or `php_serialize`, switch it to `php` at runtime before
`session_start()`, and leave session upload progress enabled. Follow PHP's
`session.upload_progress.enabled=0` guidance for that configuration until the
upstream fix is available. The SDK does not configure PHP sessions.

Keep the application's own dependency lock updated and audited:

```bash
composer update guzzlehttp/guzzle guzzlehttp/psr7 guzzlehttp/promises --with-all-dependencies
composer audit --locked
```

Keep TLS certificate verification enabled. The default transport verifies the
server certificate and hostname, bounds decoded response bytes and closes
response streams after use. Custom Guzzle clients should retain these TLS checks.
Configuration serialization/debug output and transport exception diagnostics redact
API keys in request URLs. Treat credentials and raw request objects as sensitive.

## Quick Start

```php
Expand Down Expand Up @@ -401,6 +448,29 @@ Use the raw methods with <code>output =&gt; "xml"</code>.
Check <code>$result-&gt;success</code>. Success items use <code>$result-&gt;data</code>. Error items use <code>$result-&gt;error-&gt;message</code>.
</details>

## Development and Testing

```bash
composer install
composer validate --strict
composer audit --locked
composer test:unit
```

The unit suite includes real local HTTP and TLS servers. It checks single and
bulk requests, certificate trust and hostname verification, stalled responses,
truncated responses, redirects and decoded response-size limits, including gzip.
These tests need the OpenSSL extension and `proc_open`, and require no API key.
CI runs the suite on PHP 8.1 through 8.5 and checks the lowest allowed dependency
set on PHP 8.1. Supported runtime jobs enforce the security versions listed above.

Live tests are opt-in. Supply credentials through the process environment rather
than saved files or command arguments. Set `IPGEO_RUN_LIVE_TESTS=true` and
`IPGEO_PAID_KEY` for paid-plan tests, or `IPGEO_FREE_KEY` for free-plan tests.
`IPGEO_RUN_LIVE_HARDENING=true` enables additional paid response comparisons.
Run them with `composer test:live`. Live requests consume account credits.
Never commit credentials or upload unredacted HTTP traces or exception dumps.

## Links

- API docs: <https://ipgeolocation.io/documentation/ip-location-api.html>
Expand Down
91 changes: 83 additions & 8 deletions lib/GuzzleTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\FnStream;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\ResponseInterface;

final class GuzzleTransport implements HttpTransport
{
Expand All @@ -30,12 +34,35 @@ public function __construct(

public function sendRequest(HttpRequestData $request): HttpResponseData
{
$resource = Utils::tryFopen('php://temp', 'w+');
$buffer = Utils::streamFor($resource);
$sink = FnStream::decorate($buffer, [
'write' => function (string $chunk) use ($buffer): int {
if ($buffer->tell() + strlen($chunk) > $this->max_response_body_bytes) {
throw new TransportException(
'Response body exceeded max size of ' . $this->max_response_body_bytes . ' bytes'
);
}
return $buffer->write($chunk);
},
]);
$onHeaders = $this->client->getConfig('on_headers');
try {
$response = $this->client->send(
new Request($request->method, $request->url, $request->headers, $request->body),
[
'http_errors' => false,
'stream' => true,
// cURL enforces connection and low-speed timeouts; the sink bounds decoded bytes.
'stream' => false,
'sink' => $sink,
'read_timeout' => $request->read_timeout,
'on_headers' => static function ($response) use ($resource, $buffer, $onHeaders): void {
ftruncate($resource, 0);
$buffer->rewind();
if ($onHeaders !== null && $response instanceof ResponseInterface) {
$onHeaders($response);
}
},
'connect_timeout' => $request->connect_timeout,
'curl' => [
\CURLOPT_LOW_SPEED_LIMIT => 1,
Expand All @@ -45,6 +72,12 @@ public function sendRequest(HttpRequestData $request): HttpResponseData
);

$stream = $response->getBody();
if ($stream->isSeekable()) {
$stream->rewind();
} elseif ($stream->eof() && $buffer->tell() > 0) {
$stream = $sink;
$stream->rewind();
}
$chunks = [];
$bodyBytes = 0;
while (!$stream->eof()) {
Expand Down Expand Up @@ -75,30 +108,72 @@ public function sendRequest(HttpRequestData $request): HttpResponseData
} catch (ConnectException $error) {
if ($this->isTimeoutException($error)) {
throw new RequestTimeoutException(
'HTTP request timed out after ' . (int) ($request->connect_timeout * 1000) . 'ms while opening the connection',
$error
'HTTP request timed out',
$this->redactError($error)
);
}

throw new TransportException('HTTP transport error', $error);
throw new TransportException('HTTP transport error', $this->redactError($error));
} catch (RequestException $error) {
if ($this->isTimeoutException($error)) {
throw new RequestTimeoutException(
'HTTP request timed out while waiting for response body data',
$error
'HTTP request timed out',
$this->redactError($error)
);
}

throw new TransportException('HTTP transport error', $error);
throw new TransportException('HTTP transport error', $this->redactError($error));
} catch (GuzzleException $error) {
throw new TransportException('HTTP transport error', $error);
throw new TransportException('HTTP transport error', $this->redactError($error));
} finally {
if (isset($response)) {
$response->getBody()->close();
}
$sink->close();
}
}

public function close(): void
{
}

private function redactError(GuzzleException $error): \Throwable
{
$message = self::redactApiKey($error->getMessage());
$previous = $error->getPrevious();
$safePrevious = $previous === null ? null : new \RuntimeException(
self::redactApiKey($previous->getMessage()),
(int) $previous->getCode()
);
if ($error instanceof ConnectException || $error instanceof RequestException) {
$request = $error->getRequest();
$uri = $request->getUri();
$request = $request->withUri($uri->withQuery(substr(self::redactApiKey('?' . $uri->getQuery()), 1)));
$context = $error->getHandlerContext();
array_walk_recursive($context, static function (&$value): void {
if (is_string($value)) {
$value = self::redactApiKey($value);
}
});
$class = $error::class;
$base = $error instanceof ConnectException ? ConnectException::class : RequestException::class;
// Custom exception subclasses may require a different constructor signature.
if ((new \ReflectionMethod($class, '__construct'))->getDeclaringClass()->getName() !== $base) {
$class = $base;
}
if ($error instanceof ConnectException) {
return new $class($message, $request, $safePrevious, $context);
}
return new $class($message, $request, $error->getResponse(), $safePrevious, $context);
}
return new TransferException($message, (int) $error->getCode(), $safePrevious);
}

private static function redactApiKey(string $text): string
{
return preg_replace('/([?&]apiKey=)[^&#\s]+/i', '$1[REDACTED]', $text) ?? $text;
}

private function isTimeoutException(\Throwable $error): bool
{
if (method_exists($error, 'getHandlerContext')) {
Expand Down
5 changes: 5 additions & 0 deletions lib/IpGeolocationClientConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ public function toArray(bool $keepNulls = false): array
return $data;
}

public function __debugInfo(): array
{
return $this->toArray(true);
}

private static function normalizeApiKey(?string $value): ?string
{
if ($value === null) {
Expand Down
9 changes: 9 additions & 0 deletions lib/RequestNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public static function headers(array $headers): array
if (str_contains($name, "\r") || str_contains($name, "\n")) {
throw new ValidationException('header names must not contain CR or LF');
}
if (preg_match('/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/D', $name) !== 1) {
throw new ValidationException('header names must be valid HTTP tokens');
}

$values = is_array($rawValue) ? $rawValue : [$rawValue];
if ($values === []) {
Expand All @@ -70,6 +73,9 @@ public static function headers(array $headers): array
if (str_contains($text, "\r") || str_contains($text, "\n")) {
throw new ValidationException('header values must not contain CR or LF');
}
if (preg_match('/[\x00-\x08\x0a-\x1f\x7f]/', $text) === 1) {
throw new ValidationException('header values must not contain control characters');
}

$normalizedValues[] = $text;
}
Expand All @@ -90,6 +96,9 @@ public static function optionalString(?string $value, string $field): ?string
if ($normalized === '') {
throw new ValidationException($field . ' must not be blank');
}
if (preg_match('/[\x00-\x08\x0a-\x1f\x7f]/', $normalized) === 1) {
throw new ValidationException($field . ' must not contain control characters');
}

return $normalized;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

final class Version
{
public const VERSION = '3.0.1';
public const VERSION = '3.0.2';

private function __construct()
{
Expand Down
Loading
Loading