diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 3309405..c5bad38 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -14,7 +14,7 @@ jobs: 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'] steps: - name: Checkout @@ -33,5 +33,15 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-interaction - - name: Run unit tests - run: ./vendor/bin/phpunit --exclude-group live + - name: Check dependencies + run: composer audit + + - name: Run unit and HTTP tests + run: ./vendor/bin/phpunit --exclude-group live --fail-on-warning + + - name: Check Guzzle handlers + run: | + for handler in curl stream injected; do + TEST_HTTP_HANDLER="$handler" ./vendor/bin/phpunit --filter HttpIntegrationTest --fail-on-warning + done + php -d allow_url_fopen=0 ./vendor/bin/phpunit --filter HttpIntegrationTest --fail-on-warning diff --git a/CHANGELOG.md b/CHANGELOG.md index ec617e2..1d23a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 3.0.2 + +- Hide API keys in errors and when using `print_r()` or `var_dump()` on clients or settings. +- Keep redirects from sending lookup data and custom headers to another server. +- Fix connection and response timeouts. +- Keep HTTP status codes when error responses cannot be read. +- Check bulk results for missing, extra or invalid entries. +- Add Arabic (`ar`) and the missing CDN, proxy, bot and corporate gateway fields. + +Existing public methods and PHP 8.1+ support remain available. + ## 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. diff --git a/README.md b/README.md index 091bb31..3af7dcb 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ You can pass plain arrays to request methods. If you want validation before the | Supported Endpoints | `/v3/ipgeo`, `/v3/ipgeo-bulk` | | Supported Inputs | IPv4, IPv6, domain | | Main Data Returned | Geolocation, company, ASN, time zone, network, hostname, abuse, user-agent, currency, security | -| Authentication | API key, request-origin auth for `/v3/ipgeo` only | +| Authentication | API key | | Response Formats | Structured JSON, raw JSON, raw XML | | Bulk Limit | Up to 50,000 IPs or domains per request | | Transport | Guzzle | @@ -99,7 +99,7 @@ Create an IPGeolocation.io account and copy an API key from your dashboard. 4. Open your dashboard: 5. Copy an API key from the `API Keys` section -For server-side code, keep the API key in an environment variable or secret manager. For browser-based single lookups on paid plans, use request-origin auth instead of exposing an API key in frontend code. +Keep your IPGeolocation.io API key in an environment variable or secret manager. ## Authentication @@ -111,21 +111,7 @@ $client = new IpGeolocationClient([ ]); ``` -### Request-Origin Auth - -```php -$client = new IpGeolocationClient([ - 'request_origin' => 'https://app.example.com', -]); -``` - -`request_origin` must be an absolute `http` or `https` origin with no path, query string, fragment, or userinfo. - -> [!IMPORTANT] -> Request-origin auth does not work with `/v3/ipgeo-bulk`. Bulk lookup always requires `api_key`. - -> [!NOTE] -> If you set both `api_key` and `request_origin`, both are sent. The API key appears in the query string, so avoid logging full request URLs. +The API key is sent as the `apiKey` query parameter, so avoid logging full request URLs. ## Plan Behavior @@ -137,7 +123,6 @@ Feature availability depends on your plan and request parameters. | Domain lookup | Not supported | Supported | | Bulk lookup | Not supported | Supported | | Non-English `lang` | Not supported | Supported | -| Request-origin auth | Not supported | Supported for `/v3/ipgeo` only | | Optional modules via `include` | Not supported | Supported | | `include: ["*"]` | Base response only | All plan-available modules | @@ -147,13 +132,12 @@ Paid plans still need `include` for optional modules. `fields` and `excludes` on | Field | Type | Default | Notes | |-------|------|---------|-------| -| `api_key` | `string` | unset | Required for bulk lookup. Optional for single lookup if `request_origin` is set. | -| `request_origin` | `string` | unset | Must be an absolute `http` or `https` origin. | -| `base_url` | `string` | `https://api.ipgeolocation.io` | Override the API base URL. | -| `connect_timeout` | `float` | `10.0` | Time to open the connection, in seconds. | -| `read_timeout` | `float` | `30.0` | Time to wait for response data before timing out, in seconds. | +| `api_key` | `string` | unset | Your IPGeolocation.io API key. | +| `base_url` | `string` | `https://api.ipgeolocation.io` | Use the final address of your API or proxy. Redirects are followed only when the protocol, host and port stay the same. | +| `connect_timeout` | `float` | `10.0` | Time to connect and receive response headers, in seconds. Must be greater than zero. | +| `read_timeout` | `float` | `30.0` | Time to wait for the next part of the response, in seconds. Must be greater than zero. | -The client constructor accepts either an `IpGeolocationClientConfig` or an array with the same keys. +The client constructor accepts either an `IpGeolocationClientConfig` or an array with the same keys. If you supply a custom HTTP client or transport, its own settings can also affect timeouts. ## Available Methods @@ -174,7 +158,7 @@ The client constructor accepts either an `IpGeolocationClientConfig` or an array |-------|------------|-------| | `ip` | Single lookup | IPv4, IPv6, or domain. Omit it for caller IP lookup. | | `ips` | Bulk lookup | Array of 1 to 50,000 IPs or domains. | -| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`. | +| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`, `ar` (Arabic). | | `include` | Single and bulk | Array of module names such as `security`, `abuse`, `user_agent`, `hostname`, `liveHostname`, `hostnameFallbackLive`, `geo_accuracy`, `dma_code`, or `*`. | | `fields` | Single and bulk | Array of field paths to keep, for example `["location.country_name", "security.threat_score"]`. | | `excludes` | Single and bulk | Array of field paths to remove from the response. | @@ -277,7 +261,7 @@ echo $response->data . PHP_EOL; Bulk lookup is a paid-plan feature and always requires `api_key`. -Each bulk result is either a success or an error. +Each bulk result is either a success or an error. If the API sends missing, extra or invalid rows, the typed lookup throws `SerializationException`. The raw lookup returns the response as received. ```php $response = $client->bulkLookupIpGeolocation([ @@ -326,6 +310,7 @@ echo ($metadata->firstHeaderValue('Content-Type') ?? '') . PHP_EOL; Typed response objects extend `ValueObject`, so you can turn them into arrays or JSON when you need to log, cache, or forward them. ```php +$response = $client->lookupIpGeolocation(['ip' => '8.8.8.8']); $array = $response->data->toArray(); $json = json_encode($response->data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); @@ -333,8 +318,12 @@ print_r($array); echo $json . PHP_EOL; ``` +When you use `print_r()` or `var_dump()` on a client or its settings, the SDK shows `[REDACTED]` in place of your API key. Settings printed as JSON also hide the key. You can still read the key from `$config->api_key`. + ## Errors +If a request error includes your API key, the SDK hides it. This also applies to error messages in typed bulk results. If the error response is too large or ends early, you still get the HTTP status. Some error details may be left out to keep the key hidden. + The SDK throws these exception classes: - `Ipgeolocation\Sdk\ValidationException` @@ -368,16 +357,14 @@ try { ## Troubleshooting -- `bulk lookup requires api_key in client config` - Bulk lookup does not support request-origin auth on its own. -- `single lookup requires api_key or request_origin in client config` - Set at least one authentication option before calling the single lookup methods. +- Missing API key + Set `api_key` before making a lookup. - `XML output is not supported by typed methods` Use `lookupIpGeolocationRaw` or `bulkLookupIpGeolocationRaw` for XML output. - `client is closed` Create a new client after calling `close`. - `TransportException` or `RequestTimeoutException` - Increase `connect_timeout` or `read_timeout`, or add retry logic. `read_timeout` is how long the client waits for response data before timing out. + Increase `connect_timeout` or `read_timeout`, or add retry logic. `read_timeout` is how long the client waits for the next part of the response. ## Frequently Asked Questions diff --git a/lib/ErrorRedactor.php b/lib/ErrorRedactor.php new file mode 100644 index 0000000..889ccaf --- /dev/null +++ b/lib/ErrorRedactor.php @@ -0,0 +1,56 @@ +getMessage(), $key); + if ($error instanceof ApiException) { + $class = get_class($error); + $copy = new $class($message, $error->status_code, $error->api_message === null ? null : self::text($error->api_message, $key)); + } elseif ($error instanceof \Error) { + $class = get_class($error); + $copy = new $class($message, $error->getCode()); + } else { + $class = match (true) { + $error instanceof RequestTimeoutException => RequestTimeoutException::class, + $error instanceof TransportException => TransportException::class, + $error instanceof SerializationException => SerializationException::class, + $error instanceof ValidationException => ValidationException::class, + default => TransportException::class, + }; + $copy = new $class($message); + } + + // Trace arguments can retain requests and credentials, including on PHP 8.1. + $trace = array_map(static function (array $frame): array { + unset($frame['args'], $frame['object']); + return $frame; + }, $error->getTrace()); + (new \ReflectionProperty($copy instanceof \Error ? \Error::class : \Exception::class, 'trace'))->setValue($copy, $trace); + return $copy; + } +} diff --git a/lib/Exceptions.php b/lib/Exceptions.php index 3790bc8..ab6ec7c 100644 --- a/lib/Exceptions.php +++ b/lib/Exceptions.php @@ -9,6 +9,12 @@ class IpGeolocationException extends \RuntimeException public function __construct(string $message, public readonly ?\Throwable $cause = null) { parent::__construct($message, 0, $cause); + // Arguments can contain API keys even when the error message does not. + $trace = array_map(static function (array $frame): array { + unset($frame['args'], $frame['object']); + return $frame; + }, $this->getTrace()); + (new \ReflectionProperty(\Exception::class, 'trace'))->setValue($this, $trace); } } diff --git a/lib/GuzzleTransport.php b/lib/GuzzleTransport.php index 9a5dff7..18138bb 100644 --- a/lib/GuzzleTransport.php +++ b/lib/GuzzleTransport.php @@ -7,9 +7,16 @@ use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\ConnectException; -use GuzzleHttp\Exception\GuzzleException; -use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Handler\CurlMultiHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\TransferStats; +use GuzzleHttp\Psr7\FnStream; +use GuzzleHttp\Psr7\LazyOpenStream; use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Stream; +use GuzzleHttp\Psr7\Utils; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; final class GuzzleTransport implements HttpTransport { @@ -24,74 +31,270 @@ public function __construct( if ($this->max_response_body_bytes <= 0) { throw new \InvalidArgumentException('max_response_body_bytes must be a positive integer'); } - - $this->client = $client ?? new Client(); + $this->client = $client ?? new Client(['handler' => HandlerStack::create(new CurlMultiHandler(['select_timeout' => 0.01]))]); } public function sendRequest(HttpRequestData $request): HttpResponseData { + $response = null; + $stream = null; + $sink = null; + $handle = null; + $transferStarted = null; + $deadline = null; + $timedOut = false; + $readingBody = false; + $downloadedBytes = 0; + $externalSink = null; + $bodyError = null; + $started = microtime(true); + $connectTimeout = self::timeout($request->connect_timeout); + $readTimeout = self::timeout($request->read_timeout); try { - $response = $this->client->send( - new Request($request->method, $request->url, $request->headers, $request->body), - [ - 'http_errors' => false, - 'stream' => true, - 'connect_timeout' => $request->connect_timeout, - 'curl' => [ - \CURLOPT_LOW_SPEED_LIMIT => 1, - \CURLOPT_LOW_SPEED_TIME => max(1, (int) ceil($request->read_timeout)), - ], - ] - ); - + $message = new Request($request->method, $request->url, $request->headers, $request->body); + $decode = $this->client->getConfig('decode_content'); + foreach ($this->client->getConfig('headers') ?? [] as $name => $value) { + if (strcasecmp($name, 'Accept-Encoding') === 0 && !$message->hasHeader('Accept-Encoding')) { + $message = $message->withHeader('Accept-Encoding', $value); + } + } + if ($decode !== false && !$message->hasHeader('Accept-Encoding')) { + $message = $message->withHeader('Accept-Encoding', is_string($decode) ? $decode : ''); + } + $redirects = $this->client->getConfig('allow_redirects'); + if ($redirects !== false) { + $redirects = is_array($redirects) ? $redirects : []; + $callback = $redirects['on_redirect'] ?? null; + $origin = $message->getUri(); + $redirects['on_redirect'] = static function ($previous, $reply, UriInterface $next) use ($origin, $callback): void { + if ($next->getUserInfo() !== '' || !self::sameOrigin($origin, $next)) { + throw new TransportException('Redirect to a different server is not allowed'); + } + if (is_callable($callback)) { + $callback($previous, $reply, $next); + } + }; + } + $body = ''; + $wireBytes = 0; + $inflater = null; + $usedSink = false; + $headerResponse = null; + $curlDecoded = false; + $totalTimeout = (float) ($this->client->getConfig('timeout') ?? 0); + $headerTimeout = $totalTimeout > 0 ? min($totalTimeout, $connectTimeout) : $connectTimeout; + $resetBody = static function (ResponseInterface $reply) use (&$body, &$wireBytes, &$inflater, &$curlDecoded, $decode): void { + $body = ''; + $wireBytes = 0; + $encoding = strtolower(trim($reply->getHeaderLine('Content-Encoding'))); + $inflater = !$curlDecoded && $decode !== false && function_exists('inflate_init') && in_array($encoding, ['gzip', 'deflate'], true) + ? inflate_init($encoding === 'gzip' ? ZLIB_ENCODING_GZIP : ZLIB_ENCODING_DEFLATE) + : null; + }; + $expired = static function () use (&$handle, &$deadline, &$transferStarted, &$timedOut, $totalTimeout): bool { + if ($handle === null) { + return false; + } + $now = hrtime(true) / 1e9; + $timedOut = $now >= $deadline || ($totalTimeout > 0 && $now - $transferStarted >= $totalTimeout); + return $timedOut; + }; + $refreshDeadline = static function () use (&$deadline, $readTimeout): void { + $deadline = hrtime(true) / 1e9 + $readTimeout; + }; + $consume = function (string $chunk) use (&$body, &$wireBytes, &$inflater): string { + $wireBytes += strlen($chunk); + $decoded = ''; + foreach (str_split($chunk, $inflater === null ? 8192 : 1024) as $part) { + if ($inflater !== null) { + $part = @inflate_add($inflater, $part, ZLIB_SYNC_FLUSH); + if ($part === false) { + throw new TransportException('Invalid compressed response'); + } + } + if (strlen($part) > $this->max_response_body_bytes - strlen($body)) { + throw new TransportException('Response body exceeded max size of ' . $this->max_response_body_bytes . ' bytes'); + } + $body .= $part; + $decoded .= $part; + } + return $decoded; + }; + $destination = $this->client->getConfig('sink'); + $bufferResource = fopen('php://temp', 'w+'); + $buffer = Utils::streamFor($bufferResource); + $sink = FnStream::decorate($buffer, ['write' => static function (string $chunk) use ($buffer, $consume, $expired, $refreshDeadline, &$usedSink, &$externalSink, &$bodyError, $destination): int { + $usedSink = true; + try { + if ($expired()) { + throw new RequestTimeoutException('HTTP request timed out while waiting for response data'); + } + $decoded = $consume($chunk); + if ($destination !== null && $externalSink === null) { + $externalSink = is_string($destination) ? new LazyOpenStream($destination, 'w+') : Utils::streamFor($destination); + } + if ($buffer->write($decoded) !== strlen($decoded) + || ($externalSink !== null && $externalSink->write($decoded) !== strlen($decoded))) { + throw new TransportException('Could not write the response body'); + } + $refreshDeadline(); + return strlen($chunk); + } catch (\Throwable $error) { + $bodyError = $error; + return 0; + } + }]); + $onHeaders = $this->client->getConfig('on_headers'); + $onStats = $this->client->getConfig('on_stats'); + $progress = $this->client->getConfig('progress'); + $progressOption = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION; + $curl = [$progressOption => static function ($current, $total, $downloaded, $uploadTotal, $uploaded) use (&$handle, &$transferStarted, &$deadline, &$readingBody, &$downloadedBytes, $headerTimeout, $expired, $refreshDeadline, $progress): int { + if ($handle === null) { + $transferStarted = hrtime(true) / 1e9; + $deadline = $transferStarted + $headerTimeout; + $readingBody = false; + $downloadedBytes = 0; + } + $handle = $current; + if ($expired()) { + return 1; + } + if ($readingBody && $downloaded > $downloadedBytes) { + $downloadedBytes = $downloaded; + $refreshDeadline(); + } + if (is_callable($progress)) { + $progress($total, $downloaded, $uploadTotal, $uploaded); + } + return 0; + }]; + if ($decode !== false) { + $curl[CURLOPT_ENCODING] = is_string($decode) ? $decode : ''; + } + $options = [ + 'http_errors' => false, + 'stream' => true, + 'decode_content' => false, + 'connect_timeout' => $connectTimeout, + 'timeout' => $headerTimeout, + 'sink' => $sink, + 'progress' => static function (): void {}, + 'read_timeout' => ceil($readTimeout), + 'allow_redirects' => $redirects, + 'on_headers' => static function ($reply) use (&$response, &$headerResponse, &$usedSink, &$externalSink, &$curlDecoded, &$handle, &$readingBody, $decode, $bufferResource, $destination, $resetBody, $onHeaders, $expired, $refreshDeadline, $totalTimeout): void { + if (!$reply instanceof ResponseInterface || $reply->getStatusCode() < 200) { + return; + } + $response = $headerResponse = $reply; + if ($expired()) { + throw new RequestTimeoutException('HTTP request timed out while waiting for response headers'); + } + if ($handle !== null) { + curl_setopt($handle, CURLOPT_TIMEOUT_MS, $totalTimeout > 0 ? (int) ceil(self::timeout($totalTimeout) * 1000) : 0); + } + $readingBody = true; + $curlDecoded = $handle !== null && $decode !== false && $reply->hasHeader('Content-Encoding'); + $usedSink = false; + $resetBody($reply); + ftruncate($bufferResource, 0); + rewind($bufferResource); + if (is_string($destination)) { + $externalSink?->close(); + $externalSink = null; + } + $refreshDeadline(); + if (is_callable($onHeaders)) { + $onHeaders($curlDecoded ? self::decodedHeaders($reply) : $reply); + } + }, + 'on_stats' => static function ($stats) use (&$handle, &$curlDecoded, $onStats): void { + $handle = null; + if (is_callable($onStats)) { + if ($curlDecoded && $stats->hasResponse()) { + $stats = new TransferStats($stats->getRequest(), self::decodedHeaders($stats->getResponse()), $stats->getTransferTime(), $stats->getHandlerErrorData(), $stats->getHandlerStats()); + } + $onStats($stats); + } + }, + 'curl' => $curl, + ]; + $response = $this->client->send($message, $options); + if ($bodyError !== null) { + throw $bodyError; + } $stream = $response->getBody(); - $chunks = []; - $bodyBytes = 0; - while (!$stream->eof()) { - $chunk = $stream->read(8192); - if ($chunk === '') { - continue; + if ($headerResponse === null) { + $resetBody($response); + $usedSink = false; + if ($stream->isSeekable()) { + $stream->rewind(); } - - $bodyBytes += strlen($chunk); - if ($bodyBytes > $this->max_response_body_bytes) { - throw new TransportException( - 'Response body exceeded max size of ' . $this->max_response_body_bytes . ' bytes' - ); + } + if (get_class($stream) === Stream::class) { + $resource = $stream->detach(); + if (is_resource($resource)) { + $idleTimeout = $totalTimeout > 0 ? min($totalTimeout, $readTimeout) : $readTimeout; + stream_set_timeout($resource, (int) $idleTimeout, (int) round(($idleTimeout - (int) $idleTimeout) * 1000000)); + $stream = Utils::streamFor($resource); } - - $chunks[] = $chunk; } - $body = implode('', $chunks); - - /** @var array> $headers */ $headers = $response->getHeaders(); - - return new HttpResponseData( - status_code: $response->getStatusCode(), - body: $body, - headers: $headers - ); - } 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 - ); + $encoding = strtolower(trim($response->getHeaderLine('Content-Encoding'))); + if (!$usedSink) { + while (!$stream->eof()) { + $chunk = $stream->read($inflater === null ? 8192 : 1024); + if ($stream->getMetadata('timed_out')) { + throw new RequestTimeoutException('HTTP request timed out while waiting for response data'); + } + if ($chunk === '') { + if (!$stream->eof()) { + throw new TransportException('Response stream stopped before it finished'); + } + break; + } + $consume($chunk); + } } - - throw new TransportException('HTTP transport error', $error); - } catch (RequestException $error) { - if ($this->isTimeoutException($error)) { - throw new RequestTimeoutException( - 'HTTP request timed out while waiting for response body data', - $error - ); + $length = $response->getHeaderLine('Content-Length'); + if ($curlDecoded) { + $response = self::decodedHeaders($response); + $headers = $response->getHeaders(); + $length = ''; + } + if ($encoding === '' && $response->hasHeader('x-encoded-content-encoding')) { + $length = ''; + $response = $response->withoutHeader('Content-Length'); + $headers = $response->getHeaders(); + } + if (($length !== '' && ctype_digit($length) && $wireBytes !== (int) $length) + || ($inflater !== null && (inflate_get_status($inflater) !== ZLIB_STREAM_END || inflate_get_read_len($inflater) !== $wireBytes))) { + throw new TransportException('Response body ended before it was complete'); + } + if ($inflater !== null) { + $headers = self::decodedHeaders($response)->getHeaders(); + } + return new HttpResponseData($response->getStatusCode(), $body, $headers); + } catch (\Throwable $error) { + if ($response !== null && $response->getStatusCode() >= 400) { + return new HttpResponseData($response->getStatusCode(), '', $response->getHeaders()); + } + $error = $bodyError ?? $error; + if ($error instanceof TransportException) { + throw $error; + } + if ($timedOut || ($stream !== null && $stream->getMetadata('timed_out')) || $this->isTimeoutException($error) + || ($error instanceof ConnectException && microtime(true) - $started >= ($headerTimeout ?? $connectTimeout))) { + throw new RequestTimeoutException('HTTP request timed out', $error); } - - throw new TransportException('HTTP transport error', $error); - } catch (GuzzleException $error) { throw new TransportException('HTTP transport error', $error); + } finally { + try { + $stream?->close(); + if ($sink !== $stream) { + $sink?->close(); + } + } catch (\Throwable) { + } } } @@ -99,16 +302,32 @@ public function close(): void { } - private function isTimeoutException(\Throwable $error): bool + private static function decodedHeaders(ResponseInterface $response): ResponseInterface { - if (method_exists($error, 'getHandlerContext')) { - /** @var array $context */ - $context = $error->getHandlerContext(); - if (($context['errno'] ?? null) === 28) { - return true; - } + $response = $response->withHeader('x-encoded-content-encoding', $response->getHeaderLine('Content-Encoding'))->withoutHeader('Content-Encoding'); + if ($response->hasHeader('Content-Length')) { + $response = $response->withHeader('x-encoded-content-length', $response->getHeaderLine('Content-Length'))->withoutHeader('Content-Length'); } + return $response; + } + + private static function timeout(float $seconds): float + { + return min(max($seconds, 0.001), 2147483.0); + } + private static function sameOrigin(UriInterface $left, UriInterface $right): bool + { + return strtolower($left->getScheme()) === strtolower($right->getScheme()) + && strtolower($left->getHost()) === strtolower($right->getHost()) + && ($left->getPort() ?? ($left->getScheme() === 'https' ? 443 : 80)) === ($right->getPort() ?? ($right->getScheme() === 'https' ? 443 : 80)); + } + + private function isTimeoutException(\Throwable $error): bool + { + if (method_exists($error, 'getHandlerContext') && (($error->getHandlerContext()['errno'] ?? null) === 28)) { + return true; + } return str_contains(strtolower($error->getMessage()), 'timed out'); } } diff --git a/lib/IpGeolocationClient.php b/lib/IpGeolocationClient.php index 72f64d0..91975e7 100644 --- a/lib/IpGeolocationClient.php +++ b/lib/IpGeolocationClient.php @@ -23,15 +23,20 @@ final class IpGeolocationClient */ public function __construct(IpGeolocationClientConfig|array $config, ?HttpTransport $transport = null) { - $this->config = $config instanceof IpGeolocationClientConfig - ? $config - : new IpGeolocationClientConfig( - api_key: $config['api_key'] ?? null, - request_origin: $config['request_origin'] ?? null, - base_url: $config['base_url'] ?? IpGeolocationClientConfig::DEFAULT_BASE_URL, - connect_timeout: $config['connect_timeout'] ?? IpGeolocationClientConfig::DEFAULT_CONNECT_TIMEOUT, - read_timeout: $config['read_timeout'] ?? IpGeolocationClientConfig::DEFAULT_READ_TIMEOUT - ); + try { + $this->config = $config instanceof IpGeolocationClientConfig + ? $config + : new IpGeolocationClientConfig( + api_key: $config['api_key'] ?? null, + request_origin: $config['request_origin'] ?? null, + base_url: $config['base_url'] ?? IpGeolocationClientConfig::DEFAULT_BASE_URL, + connect_timeout: $config['connect_timeout'] ?? IpGeolocationClientConfig::DEFAULT_CONNECT_TIMEOUT, + read_timeout: $config['read_timeout'] ?? IpGeolocationClientConfig::DEFAULT_READ_TIMEOUT + ); + } catch (\Throwable $error) { + $key = is_array($config) && is_string($config['api_key'] ?? null) ? $config['api_key'] : null; + throw ErrorRedactor::exception($error, $key); + } $this->transport = $transport ?? new GuzzleTransport(); $this->owns_transport = $transport === null; @@ -42,25 +47,29 @@ public static function defaultUserAgent(): string return 'ipgeolocation-php-sdk/' . Version::VERSION; } + public function __debugInfo(): array + { + return ['config' => $this->config->toArray(true), 'closed' => $this->closed]; + } + /** * @return ApiResponse */ public function lookupIpGeolocation(LookupIpGeolocationRequest|array|null $request = null): ApiResponse { - $this->ensureOpen(); - $typedRequest = $this->toLookupRequest($request); - $this->validateLookupRequestBase(); - SerDe::validateJsonOutput($typedRequest->output); + return $this->withSafeErrors(function () use ($request): ApiResponse { + $this->ensureOpen(); + $typedRequest = $this->toLookupRequest($request); + $this->validateLookupRequestBase(); + SerDe::validateJsonOutput($typedRequest->output); - [$response, $durationMs] = $this->executeWithMetrics($this->buildLookupHttpRequest($typedRequest)); - if (!$this->successStatus($response->status_code)) { - throw SerDe::toApiException($response->status_code, $response->body); - } + [$response, $durationMs] = $this->executeWithMetrics($this->buildLookupHttpRequest($typedRequest)); - return new ApiResponse( - data: SerDe::parseSingleLookup($response->body), - metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) - ); + return new ApiResponse( + data: SerDe::parseSingleLookup($response->body), + metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) + ); + }); } /** @@ -68,19 +77,18 @@ public function lookupIpGeolocation(LookupIpGeolocationRequest|array|null $reque */ public function lookupIpGeolocationRaw(LookupIpGeolocationRequest|array|null $request = null): ApiResponse { - $this->ensureOpen(); - $typedRequest = $this->toLookupRequest($request); - $this->validateLookupRequestBase(); + return $this->withSafeErrors(function () use ($request): ApiResponse { + $this->ensureOpen(); + $typedRequest = $this->toLookupRequest($request); + $this->validateLookupRequestBase(); - [$response, $durationMs] = $this->executeWithMetrics($this->buildLookupHttpRequest($typedRequest)); - if (!$this->successStatus($response->status_code)) { - throw SerDe::toApiException($response->status_code, $response->body); - } + [$response, $durationMs] = $this->executeWithMetrics($this->buildLookupHttpRequest($typedRequest)); - return new ApiResponse( - data: $response->body, - metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) - ); + return new ApiResponse( + data: $response->body, + metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) + ); + }); } /** @@ -88,20 +96,30 @@ public function lookupIpGeolocationRaw(LookupIpGeolocationRequest|array|null $re */ public function bulkLookupIpGeolocation(BulkLookupIpGeolocationRequest|array $request): ApiResponse { - $this->ensureOpen(); - $typedRequest = $this->toBulkRequest($request); - $this->validateBulkRequestBase(); - SerDe::validateJsonOutput($typedRequest->output); - - [$response, $durationMs] = $this->executeWithMetrics($this->buildBulkHttpRequest($typedRequest)); - if (!$this->successStatus($response->status_code)) { - throw SerDe::toApiException($response->status_code, $response->body); - } - - return new ApiResponse( - data: SerDe::parseBulkLookup($response->body), - metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) - ); + return $this->withSafeErrors(function () use ($request): ApiResponse { + $this->ensureOpen(); + $typedRequest = $this->toBulkRequest($request); + $this->validateBulkRequestBase(); + SerDe::validateJsonOutput($typedRequest->output); + + [$response, $durationMs] = $this->executeWithMetrics($this->buildBulkHttpRequest($typedRequest)); + $results = SerDe::parseBulkLookup($response->body); + if (count($results) !== count($typedRequest->ips)) { + throw new SerializationException('Bulk response must contain one result per input'); + } + foreach ($results as $index => $result) { + if ($result instanceof BulkLookupError && $result->error->message !== null) { + $results[$index] = new BulkLookupError(new BulkLookupErrorDetails( + ErrorRedactor::text($result->error->message, $this->config->api_key) + )); + } + } + + return new ApiResponse( + data: $results, + metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) + ); + }); } /** @@ -109,19 +127,18 @@ public function bulkLookupIpGeolocation(BulkLookupIpGeolocationRequest|array $re */ public function bulkLookupIpGeolocationRaw(BulkLookupIpGeolocationRequest|array $request): ApiResponse { - $this->ensureOpen(); - $typedRequest = $this->toBulkRequest($request); - $this->validateBulkRequestBase(); + return $this->withSafeErrors(function () use ($request): ApiResponse { + $this->ensureOpen(); + $typedRequest = $this->toBulkRequest($request); + $this->validateBulkRequestBase(); - [$response, $durationMs] = $this->executeWithMetrics($this->buildBulkHttpRequest($typedRequest)); - if (!$this->successStatus($response->status_code)) { - throw SerDe::toApiException($response->status_code, $response->body); - } + [$response, $durationMs] = $this->executeWithMetrics($this->buildBulkHttpRequest($typedRequest)); - return new ApiResponse( - data: $response->body, - metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) - ); + return new ApiResponse( + data: $response->body, + metadata: SerDe::toMetadata($response->status_code, $durationMs, $response->headers) + ); + }); } public function close(): void @@ -136,6 +153,15 @@ public function close(): void } } + private function withSafeErrors(callable $operation): ApiResponse + { + try { + return $operation(); + } catch (\Throwable $error) { + throw ErrorRedactor::exception($error, $this->config->api_key); + } + } + private function ensureOpen(): void { if ($this->closed) { @@ -282,6 +308,9 @@ private function executeWithMetrics(HttpRequestData $request): array { $startedAt = microtime(true); $response = $this->transport->sendRequest($request); + if (!$this->successStatus($response->status_code)) { + throw SerDe::toApiException($response->status_code, ErrorRedactor::text($response->body, $this->config->api_key)); + } $durationMs = (int) floor((microtime(true) - $startedAt) * 1000); return [$response, max($durationMs, 0)]; diff --git a/lib/IpGeolocationClientConfig.php b/lib/IpGeolocationClientConfig.php index 2b31ddb..3697f49 100644 --- a/lib/IpGeolocationClientConfig.php +++ b/lib/IpGeolocationClientConfig.php @@ -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) { diff --git a/lib/Models.php b/lib/Models.php index 78bf0da..b21d697 100644 --- a/lib/Models.php +++ b/lib/Models.php @@ -179,7 +179,9 @@ class Network extends ValueObject public function __construct( public readonly ?string $connection_type = null, public readonly ?string $route = null, - public readonly ?bool $is_anycast = null + public readonly ?bool $is_anycast = null, + public readonly ?bool $is_cdn = null, + public readonly ?string $cdn_provider_name = null ) { } } @@ -209,7 +211,18 @@ public function __construct( public readonly ?bool $is_bot = null, public readonly ?bool $is_spam = null, public readonly ?bool $is_cloud_provider = null, - public readonly ?string $cloud_provider_name = null + public readonly ?string $cloud_provider_name = null, + public readonly ?float $proxy_days_seen_30d = null, + public readonly ?float $proxy_days_seen_7d = null, + public readonly ?float $proxy_provider_count_30d = null, + public readonly ?float $bot_confidence_score = null, + public readonly ?string $bot_operator_name = null, + public readonly ?string $bot_type = null, + public readonly ?bool $is_known_good_bot = null, + public readonly ?string $bot_last_seen = null, + public readonly ?bool $is_corporate_gateway = null, + public readonly ?string $corporate_gateway_type = null, + public readonly ?string $corporate_gateway_provider_name = null ) { } } diff --git a/lib/RequestNormalizer.php b/lib/RequestNormalizer.php index b45b0ad..6ca6d60 100644 --- a/lib/RequestNormalizer.php +++ b/lib/RequestNormalizer.php @@ -47,8 +47,8 @@ public static function headers(array $headers): array throw new ValidationException('header names must not be blank'); } - 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', $rawName) !== 1) { + throw new ValidationException('header name is not valid'); } $values = is_array($rawValue) ? $rawValue : [$rawValue]; @@ -67,8 +67,8 @@ public static function headers(array $headers): array throw new ValidationException('header values must not contain blank strings'); } - 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]/', $value) === 1) { + throw new ValidationException('header value contains a control character'); } $normalizedValues[] = $text; @@ -91,6 +91,10 @@ public static function optionalString(?string $value, string $field): ?string throw new ValidationException($field . ' must not be blank'); } + if ($field === 'user_agent' && preg_match('/[\\x00-\\x08\\x0a-\\x1f\\x7f]/', $value) === 1) { + throw new ValidationException('user_agent contains a control character'); + } + return $normalized; } @@ -105,7 +109,7 @@ public static function language(?string $value): ?string throw new ValidationException('lang must not be blank'); } - $allowed = ['en', 'de', 'ru', 'ja', 'fr', 'cn', 'es', 'cs', 'it', 'ko', 'fa', 'pt']; + $allowed = ['en', 'de', 'ru', 'ja', 'fr', 'cn', 'es', 'cs', 'it', 'ko', 'fa', 'pt', 'ar']; if (!in_array($normalized, $allowed, true)) { throw new ValidationException('lang is not supported'); } diff --git a/lib/SerDe.php b/lib/SerDe.php index 0433585..b13f3bd 100644 --- a/lib/SerDe.php +++ b/lib/SerDe.php @@ -95,11 +95,11 @@ public static function validateJsonOutput(string $output): void public static function parseSingleLookup(string $body): IpGeolocationResponse { $payload = self::parseJson($body, 'Failed to deserialize API response'); - if (!is_array($payload) || array_is_list($payload)) { + if (!$payload instanceof \stdClass) { throw new SerializationException('Failed to deserialize API response'); } - return self::parseIpGeolocationResponse($payload); + return self::parseIpGeolocationResponse(get_object_vars($payload)); } /** @@ -114,9 +114,10 @@ public static function parseBulkLookup(string $body): array $results = []; foreach ($payload as $item) { - if (!is_array($item)) { + if (!$item instanceof \stdClass) { throw new SerializationException('Failed to deserialize API response'); } + $item = get_object_vars($item); if (self::bulkErrorItem($item)) { $results[] = new BulkLookupError( @@ -181,7 +182,7 @@ public static function extractApiMessage(?string $body): ?string $message = $payload['error']['message'] ?? null; } - return self::stringOrNull($message) ?? substr($normalized, 0, 512); + return is_string($message) ? $message : substr($normalized, 0, 512); } /** @@ -243,7 +244,7 @@ private static function parseIntHeader(?string $value): ?int private static function parseJson(string $body, string $message): mixed { try { - return json_decode($body, true, 512, JSON_THROW_ON_ERROR); + return json_decode($body, false, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $error) { throw new SerializationException($message, $error); } @@ -394,7 +395,9 @@ private static function parseNetwork(array $node): Network return new Network( connection_type: self::stringOrNull($node['connection_type'] ?? null), route: self::stringOrNull($node['route'] ?? null), - is_anycast: self::boolOrNull($node['is_anycast'] ?? null) + is_anycast: self::boolOrNull($node['is_anycast'] ?? null), + is_cdn: self::boolOrNull($node['is_cdn'] ?? null), + cdn_provider_name: self::stringOrNull($node['cdn_provider_name'] ?? null) ); } @@ -422,7 +425,18 @@ private static function parseSecurity(array $node): Security is_bot: self::boolOrNull($node['is_bot'] ?? null), is_spam: self::boolOrNull($node['is_spam'] ?? null), is_cloud_provider: self::boolOrNull($node['is_cloud_provider'] ?? null), - cloud_provider_name: self::stringOrNull($node['cloud_provider_name'] ?? null) + cloud_provider_name: self::stringOrNull($node['cloud_provider_name'] ?? null), + proxy_days_seen_30d: self::numericOrNull($node['proxy_days_seen_30d'] ?? null), + proxy_days_seen_7d: self::numericOrNull($node['proxy_days_seen_7d'] ?? null), + proxy_provider_count_30d: self::numericOrNull($node['proxy_provider_count_30d'] ?? null), + bot_confidence_score: self::numericOrNull($node['bot_confidence_score'] ?? null), + bot_operator_name: self::stringOrNull($node['bot_operator_name'] ?? null), + bot_type: self::stringOrNull($node['bot_type'] ?? null), + is_known_good_bot: self::boolOrNull($node['is_known_good_bot'] ?? null), + bot_last_seen: self::stringOrNull($node['bot_last_seen'] ?? null), + is_corporate_gateway: self::boolOrNull($node['is_corporate_gateway'] ?? null), + corporate_gateway_type: self::stringOrNull($node['corporate_gateway_type'] ?? null), + corporate_gateway_provider_name: self::stringOrNull($node['corporate_gateway_provider_name'] ?? null) ); } @@ -517,15 +531,11 @@ private static function parseOptionalObject(mixed $value, callable $parser): mix return null; } - if (!is_array($value)) { - throw new SerializationException('Failed to deserialize API response'); - } - - if ($value !== [] && array_is_list($value)) { + if (!$value instanceof \stdClass) { throw new SerializationException('Failed to deserialize API response'); } - return $parser($value); + return $parser(get_object_vars($value)); } private static function stringOrNull(mixed $value): ?string @@ -605,9 +615,8 @@ private static function bulkErrorItem(array $item): bool } return isset($item['error']) - && is_array($item['error']) - && !array_is_list($item['error']) - && array_key_exists('message', $item['error']); + && $item['error'] instanceof \stdClass + && property_exists($item['error'], 'message'); } /** @@ -620,10 +629,10 @@ private static function bulkErrorMessage(array $item): ?string return $message; } - if (!isset($item['error']) || !is_array($item['error']) || array_is_list($item['error'])) { + if (!isset($item['error']) || !$item['error'] instanceof \stdClass) { return null; } - return self::stringOrNull($item['error']['message'] ?? null); + return self::stringOrNull($item['error']->message ?? null); } } diff --git a/lib/Version.php b/lib/Version.php index eb75b4e..2381960 100644 --- a/lib/Version.php +++ b/lib/Version.php @@ -6,7 +6,7 @@ final class Version { - public const VERSION = '3.0.1'; + public const VERSION = '3.0.2'; private function __construct() { diff --git a/tests/ip_location/GuzzleTransportTest.php b/tests/ip_location/GuzzleTransportTest.php index 3efac8e..1efd029 100644 --- a/tests/ip_location/GuzzleTransportTest.php +++ b/tests/ip_location/GuzzleTransportTest.php @@ -8,7 +8,6 @@ use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Ipgeolocation\Sdk\GuzzleTransport; @@ -19,33 +18,6 @@ final class GuzzleTransportTest extends TestCase { - public function testPassesBodyProgressTimeoutToCurlOptions(): void - { - $history = []; - $stack = HandlerStack::create(new MockHandler([ - new Response(200, [], '{"ip":"8.8.8.8"}'), - ])); - $stack->push(Middleware::history($history)); - - $transport = new GuzzleTransport(new Client([ - 'handler' => $stack, - ])); - - $transport->sendRequest(new HttpRequestData( - url: 'https://api.ipgeolocation.io/v3/ipgeo?apiKey=test-key', - method: 'GET', - headers: ['Accept' => ['application/json']], - body: null, - connect_timeout: 10.0, - read_timeout: 2.5 - )); - - self::assertCount(1, $history); - self::assertSame(10.0, $history[0]['options']['connect_timeout']); - self::assertSame(1, $history[0]['options']['curl'][\CURLOPT_LOW_SPEED_LIMIT]); - self::assertSame(3, $history[0]['options']['curl'][\CURLOPT_LOW_SPEED_TIME]); - } - public function testReturnsResponseData(): void { $transport = new GuzzleTransport(new Client([ @@ -68,6 +40,17 @@ public function testReturnsResponseData(): void self::assertSame(['1'], $response->headers['X-Credits-Charged']); } + public function testCompressedResponseWithoutHeaderCallbackIsDecoded(): void + { + $body = '{"ip":"8.8.8.8"}'; + $compressed = gzencode($body); + $transport = new GuzzleTransport(new Client(['handler' => HandlerStack::create(new MockHandler([ + static fn () => new Response(200, ['Content-Encoding' => 'gzip', 'Content-Length' => strlen($compressed)], $compressed), + ]))])); + $result = $transport->sendRequest(new HttpRequestData('https://example.test', 'GET', [], null, 1, 1)); + self::assertSame($body, $result->body); + } + public function testMapsTimeoutsToRequestTimeoutException(): void { $transport = new GuzzleTransport(new Client([ diff --git a/tests/ip_location/HardeningTest.php b/tests/ip_location/HardeningTest.php new file mode 100644 index 0000000..e434714 --- /dev/null +++ b/tests/ip_location/HardeningTest.php @@ -0,0 +1,223 @@ +push(new HttpResponseData($status, $body, [])); + return new IpGeolocationClient(['api_key' => self::KEY], $transport); + } + + public function testArabicAndAuthenticationForBothRequests(): void + { + $this->client(200, '{"ip":"8.8.8.8"}', $single)->lookupIpGeolocation(new LookupIpGeolocationRequest(ip: '8.8.8.8', lang: ' AR ')); + $this->client(200, '[{"ip":"8.8.8.8"}]', $bulk)->bulkLookupIpGeolocation(new BulkLookupIpGeolocationRequest(['8.8.8.8'], lang: 'ar')); + foreach ([$single, $bulk] as $transport) { + parse_str(parse_url($transport->requests[0]->url, PHP_URL_QUERY), $query); + self::assertSame(self::KEY, $query['apiKey']); + self::assertSame('ar', $query['lang']); + } + } + + public function testPrintingSettingsDoesNotChangeTheStoredKey(): void + { + $config = new IpGeolocationClientConfig(api_key: self::KEY); + foreach ([$config, new IpGeolocationClient($config)] as $value) { + ob_start(); + var_dump($value); + $dump = ob_get_clean(); + self::assertStringNotContainsString(self::KEY, $dump . print_r($value, true)); + self::assertStringContainsString('[REDACTED]', $dump); + } + foreach ([false, true] as $keepNulls) { + self::assertSame('[REDACTED]', $config->toArray($keepNulls)['api_key']); + } + self::assertSame('[REDACTED]', json_decode(json_encode($config), true)['api_key']); + self::assertSame(self::KEY, $config->api_key); + } + + #[DataProvider('errorBodies')] + public function testHttpErrorsKeepStatusAndHideTheKey(string $body): void + { + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $this->client(429, $body)->lookupIpGeolocation(['ip' => '8.8.8.8']); + self::fail('Expected an HTTP error'); + } catch (ApiException $error) { + self::assertSame(429, $error->status_code); + self::assertInstanceOf(\Ipgeolocation\Sdk\RateLimitException::class, $error); + foreach ([self::KEY, rawurlencode(self::KEY), urlencode(self::KEY)] as $secret) { + self::assertStringNotContainsString($secret, (string) $error . print_r($error, true)); + } + foreach ($error->getTrace() as $frame) { + self::assertArrayNotHasKey('args', $frame); + } + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public static function errorBodies(): array + { + return array_map(static fn ($body) => [$body], [ + json_encode(['message' => self::KEY]), + json_encode(['error' => ['message' => rawurlencode(self::KEY)]]), + json_encode(['message' => ['invalid' => self::KEY]]), + '' . self::KEY . '', '', 'null', '[]', 'false', + '{"message":123}', '{"message":false}', '{"message":null}', + str_repeat('x', 505) . self::KEY, + ]); + } + + public function testTransportErrorsDoNotRetainTheRequestOrCause(): void + { + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $transport = new FakeTransport(); + $transport->push(new ConnectException('Failed ' . rawurlencode(self::KEY), new Request('GET', 'https://example.test/?apiKey=' . rawurlencode(self::KEY)))); + (new IpGeolocationClient(['api_key' => self::KEY], $transport))->lookupIpGeolocation(); + self::fail('Expected transport error'); + } catch (TransportException $error) { + self::assertNull($error->getPrevious()); + self::assertNull($error->cause); + self::assertStringNotContainsString(self::KEY, print_r($error, true)); + self::assertStringNotContainsString(rawurlencode(self::KEY), (string) $error . print_r($error, true)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testValidationTraceDoesNotRetainConfigArguments(): void + { + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + new IpGeolocationClientConfig(api_key: self::KEY, read_timeout: 0); + self::fail('Expected validation error'); + } catch (ValidationException $error) { + self::assertStringNotContainsString(self::KEY, print_r($error, true)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testParseErrorsDoNotRetainTheOriginalBody(): void + { + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + $this->client(200, '{"ip":' . json_encode(self::KEY))->lookupIpGeolocation(); + self::fail('Expected a parse error'); + } catch (SerializationException $error) { + self::assertNull($error->getPrevious()); + self::assertNull($error->cause); + self::assertStringNotContainsString(self::KEY, print_r($error, true)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testInvalidInputKeepsItsErrorType(): void + { + foreach ([['headers' => ['X-Test' => 1]], ['fields' => [1]]] as $request) { + try { + $this->client(200, '{}')->lookupIpGeolocation($request); + self::fail('Expected a type error'); + } catch (\TypeError $error) { + self::assertStringNotContainsString(self::KEY, print_r($error, true)); + } + } + } + + public function testConstructorTypeErrorsDoNotRetainSettings(): void + { + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + new IpGeolocationClient(['api_key' => self::KEY, 'read_timeout' => []]); + self::fail('Expected a type error'); + } catch (\TypeError $error) { + self::assertStringNotContainsString(self::KEY, print_r($error, true)); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + } + + public function testOnlyTypedBulkErrorMessagesAreRedacted(): void + { + $body = json_encode([['ip' => self::KEY], ['error' => ['message' => self::KEY]]]); + $typed = $this->client(200, $body)->bulkLookupIpGeolocation(['ips' => ['8.8.8.8', '1.1.1.1']]); + self::assertSame(self::KEY, $typed->data[0]->data->ip); + self::assertInstanceOf(BulkLookupError::class, $typed->data[1]); + self::assertSame('[REDACTED]', $typed->data[1]->error->message); + self::assertSame($body, $this->client(200, $body)->bulkLookupIpGeolocationRaw(['ips' => ['8.8.8.8', '1.1.1.1']])->data); + self::assertSame(self::KEY, $this->client(200, json_encode(['ip' => self::KEY]))->lookupIpGeolocation()->data->ip); + } + + #[DataProvider('invalidBulkBodies')] + public function testInvalidBulkResultsAreRejectedButRawResultsAreUnchanged(string $body): void + { + self::assertSame($body, $this->client(200, $body)->bulkLookupIpGeolocationRaw(['ips' => ['8.8.8.8']])->data); + $this->expectException(SerializationException::class); + $this->client(200, $body)->bulkLookupIpGeolocation(['ips' => ['8.8.8.8']]); + } + + public static function invalidBulkBodies(): array + { + return array_map(static fn ($body) => [$body], ['[]', '[{},{}]', '[[]]', '[null]', '[false]', '[1]', '["value"]', '{}', '{"0":{}}', '[{"location":[]}]', '[{"message":[]}]', '[{"ip":1}]', '[{"ip":"bad\\uD800"}]']); + } + + public function testFilteredEmptyObjectsAndDuplicateInputsKeepTheirRows(): void + { + $rows = $this->client(200, '[{},{}]')->bulkLookupIpGeolocation(['ips' => ['8.8.8.8', '8.8.8.8']])->data; + self::assertCount(2, $rows); + self::assertTrue($rows[0]->isSuccess()); + self::assertSame([], $rows[0]->data->toArray()); + } + + public function testAllNewFieldsAreParsed(): void + { + $network = ['is_cdn' => false, 'cdn_provider_name' => 'Example']; + $security = ['proxy_days_seen_30d' => 3, 'proxy_days_seen_7d' => 1, 'proxy_provider_count_30d' => 2, 'bot_confidence_score' => 0.5, 'bot_operator_name' => 'Example', 'bot_type' => 'crawler', 'is_known_good_bot' => false, 'bot_last_seen' => '2026-11-06', 'is_corporate_gateway' => true, 'corporate_gateway_type' => 'sase', 'corporate_gateway_provider_name' => 'Example']; + $result = SerDe::parseSingleLookup(json_encode(['network' => $network, 'security' => $security])); + foreach ($network as $key => $value) { + self::assertSame($value, $result->network->$key); + } + foreach ($security as $key => $value) { + self::assertSame(is_int($value) ? (float) $value : $value, $result->security->$key); + } + } + + #[DataProvider('invalidHeaders')] + public function testInvalidHeadersAreRejectedBeforeTransport(array $request): void + { + $this->expectException(ValidationException::class); + new LookupIpGeolocationRequest(...$request); + } + + public static function invalidHeaders(): array + { + return [[['headers' => ['Bad Name' => 'x']]], [['headers' => ['Bad:Name' => 'x']]], [['headers' => ["X-Test\r\n" => 'x']]], [['headers' => ['X-Test' => "\nx"]]], [['headers' => ['X-Test' => "x\0y"]]], [['user_agent' => "x\r\nX-Test: injected"]]]; + } +} diff --git a/tests/ip_location/HttpIntegrationTest.php b/tests/ip_location/HttpIntegrationTest.php new file mode 100644 index 0000000..bc24f6e --- /dev/null +++ b/tests/ip_location/HttpIntegrationTest.php @@ -0,0 +1,332 @@ + 'test-key', 'base_url' => self::$base . '/' . $route, 'connect_timeout' => $timeout, 'read_timeout' => $timeout], $transport ?? $this->transport()); + } + + private function transport(int $limit = GuzzleTransport::DEFAULT_MAX_RESPONSE_BODY_BYTES, array $options = []): GuzzleTransport + { + $mode = getenv('TEST_HTTP_HANDLER'); + if ($mode === 'curl' || $mode === 'stream') { + $options['handler'] = HandlerStack::create($mode === 'curl' ? new CurlHandler() : new StreamHandler()); + } + return new GuzzleTransport($options === [] && $mode !== 'injected' ? null : new Client($options), $limit); + } + + #[DataProvider('workingRoutes')] + public function testSuccessPreservesData(string $route): void + { + $result = $this->client($route)->lookupIpGeolocation(['ip' => '8.8.8.8', 'lang' => 'ar']); + self::assertSame('8.8.8.8', $result->data->ip); + self::assertSame('مصر', $result->data->location->country_name); + if (in_array($route, ['gzip', 'deflate'], true)) { + self::assertNull($result->metadata->firstHeaderValue('Content-Encoding')); + self::assertNotNull($result->metadata->firstHeaderValue('x-encoded-content-length')); + } + } + + public static function workingRoutes(): array + { + return [['echo'], ['gzip'], ['deflate'], ['redirect-same']]; + } + + #[DataProvider('redirectRoutes')] + public function testRedirectsCannotSendDataElsewhere(string $route): void + { + try { + $this->client($route)->bulkLookupIpGeolocation(['ips' => ['8.8.8.8'], 'headers' => ['X-Marker' => 'test-marker']]); + self::fail('Expected redirect rejection'); + } catch (TransportException $error) { + self::assertStringNotContainsString('test-key', (string) $error); + } + $received = (new GuzzleTransport())->sendRequest(new HttpRequestData(self::$base . '/received', 'GET', [], null, 1, 1)); + self::assertSame([], json_decode($received->body, true)); + } + + public static function redirectRoutes(): array + { + return [['redirect-other'], ['redirect-port'], ['redirect-user'], ['redirect-loop']]; + } + + #[DataProvider('delayedRoutes')] + public function testRealTimeouts(string $route): void + { + $start = microtime(true); + try { + $this->client($route, 0.05)->lookupIpGeolocation(); + self::fail('Expected timeout'); + } catch (RequestTimeoutException) { + $elapsed = microtime(true) - $start; + self::assertGreaterThan(0.025, $elapsed); + self::assertLessThan(getenv('TEST_HTTP_HANDLER') === 'curl' ? 1.2 : 0.3, $elapsed); + } + } + + public static function delayedRoutes(): array + { + return [['headers-delay'], ['body-delay'], ['body-stall']]; + } + + public function testHealthyProgressCanTakeLongerThanEitherTimeout(): void + { + $start = microtime(true); + self::assertSame('8.8.8.8', $this->client('progress', 0.15)->lookupIpGeolocation()->data->ip); + self::assertGreaterThan(0.2, microtime(true) - $start); + } + + public function testCompressedResponseCanKeepMakingProgress(): void + { + self::assertSame('8.8.8.8', $this->client('gzip-progress', 0.05)->lookupIpGeolocation()->data->ip); + } + + public function testSlowResponseStaysWithinTheIdleTimeout(): void + { + $result = $this->client('trickle', 2, new GuzzleTransport())->lookupIpGeolocation(); + self::assertNull($result->data->ip); + } + + public function testVeryLargeTimeoutsRemainUsable(): void + { + self::assertSame('8.8.8.8', $this->client('echo', PHP_FLOAT_MAX)->lookupIpGeolocation()->data->ip); + } + + public function testVerySmallTimeoutsExpire(): void + { + $this->expectException(RequestTimeoutException::class); + $this->client('headers-delay', 0.0000001, new GuzzleTransport())->lookupIpGeolocation(); + } + + #[DataProvider('brokenBodies')] + public function testInvalidSuccessfulBodiesFail(string $route, int $limit): void + { + $this->expectException(TransportException::class); + $this->client($route, 1, $this->transport($limit))->lookupIpGeolocation(); + } + + public static function brokenBodies(): array + { + return [['cut', 4096], ['gzip-cut', 4096], ['bad-gzip', 4096], ['gzip-big', 1024], ['echo', 16]]; + } + + #[DataProvider('unreadableErrors')] + public function testHttpErrorsKeepStatusEvenWhenBodyFails(string $route): void + { + try { + $this->client($route, 0.05, $this->transport(1024))->lookupIpGeolocation(); + self::fail('Expected API error'); + } catch (RateLimitException $error) { + self::assertSame(429, $error->status_code); + self::assertNull($error->api_message); + } + } + + public static function unreadableErrors(): array + { + return [['error-cut'], ['error-gzip-cut'], ['error-large'], ['error-delay']]; + } + + public function testExplicitDecodeFalseReturnsCompressedBytes(): void + { + $result = $this->client('gzip', 1, $this->transport(4096, ['decode_content' => false]))->lookupIpGeolocationRaw(); + self::assertSame('gzip', $result->metadata->firstHeaderValue('Content-Encoding')); + self::assertSame('8.8.8.8', json_decode(gzdecode($result->data), true)['ip']); + } + + public function testExactSizeBoundaryAndLargerLimit(): void + { + $body = $this->client('echo')->lookupIpGeolocationRaw()->data; + foreach ([strlen($body), strlen($body) + 1] as $limit) { + self::assertSame($body, $this->client('echo', 1, $this->transport($limit))->lookupIpGeolocationRaw()->data); + } + $this->expectException(TransportException::class); + $this->client('echo', 1, $this->transport(strlen($body) - 1))->lookupIpGeolocationRaw(); + } + + public function testCustomCurlTotalTimeoutIsPreserved(): void + { + $transport = new GuzzleTransport(new Client(['handler' => HandlerStack::create(new CurlHandler()), 'timeout' => 0.05])); + $this->expectException(RequestTimeoutException::class); + $this->client('progress', 1, $transport)->lookupIpGeolocation(); + } + + public function testCurlSupportsBrotliAndConfiguredEncodingHeaders(): void + { + if (empty(curl_version()['brotli_version'])) { + self::markTestSkipped('This libcurl build has no Brotli decoder'); + } + foreach ([['decode_content' => 'br'], ['headers' => ['Accept-Encoding' => 'br']]] as $options) { + $options['handler'] = HandlerStack::create(new CurlHandler()); + $transport = new GuzzleTransport(new Client($options)); + $result = $this->client('br', 1, $transport)->lookupIpGeolocation(); + self::assertSame('8.8.8.8', $result->data->ip); + self::assertNull($result->metadata->firstHeaderValue('Content-Encoding')); + $headers = json_decode($this->client('request-headers', 1, $transport)->lookupIpGeolocationRaw()->data, true); + self::assertSame('br', $headers['Accept-Encoding']); + } + } + + public function testCustomCallbacksStillRun(): void + { + $headers = []; + $stats = []; + $progress = 0; + $redirects = 0; + $transport = new GuzzleTransport(new Client([ + 'handler' => HandlerStack::create(new CurlHandler()), + 'on_headers' => static function ($response) use (&$headers): void { $headers[] = $response->getHeaders(); }, + 'on_stats' => static function ($result) use (&$stats): void { $stats[] = $result->getResponse()->getHeaders(); }, + 'progress' => static function () use (&$progress): void { ++$progress; }, + 'allow_redirects' => ['on_redirect' => static function () use (&$redirects): void { ++$redirects; }], + ])); + $this->client('gzip', 1, $transport)->lookupIpGeolocation(); + self::assertArrayNotHasKey('Content-Encoding', $headers[0]); + self::assertSame($headers[0], $stats[0]); + $this->client('redirect-same', 1, $transport)->lookupIpGeolocation(); + self::assertSame(1, $redirects); + self::assertCount(3, $stats); + self::assertGreaterThan(0, $progress); + } + + public function testDefaultClientRejectsUnfinishedChunkedResponse(): void + { + $this->expectException(TransportException::class); + $this->client('chunked-cut', 1, new GuzzleTransport())->lookupIpGeolocation(); + } + + public function testDefaultClientKeepsStatusForUnfinishedChunkedError(): void + { + $this->expectException(RateLimitException::class); + $this->client('error-chunked-cut', 1, new GuzzleTransport())->lookupIpGeolocation(); + } + + public function testCurlResetsRedirectBodyAndRunsFailureStats(): void + { + $sizes = []; + $path = tempnam(sys_get_temp_dir(), 'ipgeo-redirect-'); + try { + $transport = new GuzzleTransport(new Client([ + 'handler' => HandlerStack::create(new CurlHandler()), + 'sink' => $path, + 'on_stats' => static function ($stats) use (&$sizes): void { + $sizes[] = $stats->getResponse()?->getBody()->getSize(); + }, + ])); + $body = $this->client('redirect-body', 1, $transport)->lookupIpGeolocationRaw()->data; + self::assertSame([2048, strlen($body)], $sizes); + self::assertSame($body, file_get_contents($path)); + $failures = 0; + $transport = new GuzzleTransport(new Client([ + 'handler' => HandlerStack::create(new CurlHandler()), + 'on_stats' => static function () use (&$failures): void { ++$failures; }, + ]), 16); + try { + $this->client('echo', 1, $transport)->lookupIpGeolocation(); + self::fail('Expected size limit'); + } catch (TransportException) { + self::assertSame(1, $failures); + } + } finally { + unlink($path); + } + } + + public function testCustomCurlSinkReceivesDecodedData(): void + { + $path = tempnam(sys_get_temp_dir(), 'ipgeo-response-'); + try { + $transport = new GuzzleTransport(new Client(['handler' => HandlerStack::create(new CurlHandler()), 'sink' => $path])); + $body = $this->client('gzip', 1, $transport)->lookupIpGeolocationRaw()->data; + self::assertSame($body, file_get_contents($path)); + self::assertSame('8.8.8.8', json_decode($body, true)['ip']); + } finally { + unlink($path); + } + } + + public function testStreamHandlerLeavesUnusedSinkOpen(): void + { + if (!ini_get('allow_url_fopen')) { + self::markTestSkipped('The native stream handler is disabled'); + } + $sink = fopen('php://temp', 'w+'); + $transport = new GuzzleTransport(new Client(['handler' => HandlerStack::create(new StreamHandler()), 'sink' => $sink])); + $this->client('echo', 1, $transport)->lookupIpGeolocation(); + self::assertIsResource($sink); + self::assertSame(0, ftell($sink)); + fclose($sink); + } + + public function testNativeCurlOptionsCannotBypassRedirectOrSizeChecks(): void + { + $transport = new GuzzleTransport(new Client(['handler' => HandlerStack::create(new CurlHandler()), 'curl' => [CURLOPT_FOLLOWLOCATION => true, CURLOPT_NOPROGRESS => true]])); + try { + $this->client('redirect-other', 1, $transport)->bulkLookupIpGeolocation(['ips' => ['8.8.8.8']]); + self::fail('Expected redirect rejection'); + } catch (TransportException) { + $received = (new GuzzleTransport())->sendRequest(new HttpRequestData(self::$base . '/received', 'GET', [], null, 1, 1)); + self::assertSame([], json_decode($received->body, true)); + } + $transport = new GuzzleTransport(new Client(['handler' => HandlerStack::create(new CurlHandler()), 'curl' => [CURLOPT_WRITEFUNCTION => static fn ($handle, $body) => strlen($body)]]), 16); + $this->expectException(TransportException::class); + $this->client('echo', 1, $transport)->lookupIpGeolocation(); + } + + public function testFiftyThousandResultsRemainComplete(): void + { + $previous = ini_set('memory_limit', '1G'); + try { + $results = $this->client('large', 10)->bulkLookupIpGeolocation(['ips' => array_fill(0, 50000, '8.8.8.8')])->data; + self::assertCount(50000, $results); + self::assertSame('مصر', $results[49999]->data->location->country_name); + self::assertSame(str_repeat('x', 2048), $results[49999]->data->location->city); + } finally { + unset($results); + gc_mem_caches(); + ini_set('memory_limit', $previous); + } + } +} diff --git a/tests/ip_location/LiveFieldParityTest.php b/tests/ip_location/LiveFieldParityTest.php index 5dd3374..8c8cbf1 100644 --- a/tests/ip_location/LiveFieldParityTest.php +++ b/tests/ip_location/LiveFieldParityTest.php @@ -5,6 +5,7 @@ namespace Ipgeolocation\Sdk\Test; use Ipgeolocation\Sdk\IpGeolocationClient; +use Ipgeolocation\Sdk\SerDe; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -38,6 +39,13 @@ public function testIncludeStarResponseMatchesTypedModel(): void ]); $payload = json_decode($raw->data, true, 512, JSON_THROW_ON_ERROR); + $mapped = SerDe::parseSingleLookup($raw->data)->toArray(true); + foreach (['network', 'security'] as $section) { + foreach ($payload[$section] ?? [] as $field => $value) { + self::assertArrayHasKey($field, $mapped[$section], $section . '.' . $field); + self::assertEquals($value, $mapped[$section][$field], $section . '.' . $field); + } + } self::assertSame($payload['ip'] ?? null, $typed->data->ip); self::assertSame($payload['location']['country_name'] ?? null, $typed->data->location?->country_name); } @@ -64,4 +72,19 @@ public function testFilteredSecurityResponseMatchesTypedModel(): void self::assertSame((float) ($payload['security']['threat_score'] ?? 0), $typed->data->security?->threat_score); self::assertSame($payload['security']['is_vpn'] ?? null, $typed->data->security?->is_vpn); } + + public function testArabicSingleAndBulkKeepInputOrder(): void + { + if (!self::shouldRunLiveHardening()) { + self::markTestSkipped('Set IPGEO_RUN_LIVE_HARDENING=true and IPGEO_PAID_KEY to enable live tests'); + } + $client = $this->paidClient(); + $single = $client->lookupIpGeolocation(['ip' => '8.8.8.8', 'lang' => 'ar']); + self::assertMatchesRegularExpression('/[\x{0600}-\x{06FF}]/u', $single->data->location->country_name); + $bulk = $client->bulkLookupIpGeolocation(['ips' => ['8.8.8.8', 'invalid-ip', '8.8.8.8'], 'lang' => 'ar']); + self::assertCount(3, $bulk->data); + self::assertSame($single->data->location->country_name, $bulk->data[0]->data->location->country_name); + self::assertFalse($bulk->data[1]->success); + self::assertSame($bulk->data[0]->data->ip, $bulk->data[2]->data->ip); + } } diff --git a/tests/ip_location/Support/http_fixture.py b/tests/ip_location/Support/http_fixture.py new file mode 100644 index 0000000..1197cfb --- /dev/null +++ b/tests/ip_location/Support/http_fixture.py @@ -0,0 +1,134 @@ +import gzip +import json +import threading +import time +import zlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +foreign_requests = [] + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + self.do_GET() + + def do_GET(self): + try: + self.respond() + except (BrokenPipeError, ConnectionResetError): + pass + + def respond(self): + route = urlsplit(self.path).path.split('/')[1] + request = self.rfile.read(int(self.headers.get('Content-Length', 0))) + if self.server is foreign: + foreign_requests.append({'body': request.decode(), 'marker': self.headers.get('X-Marker')}) + if route == 'received': + body = json.dumps(foreign_requests).encode() + else: + row = {'ip': '8.8.8.8', 'location': {'country_name': 'مصر'}} + body = json.dumps([row for _ in json.loads(request)['ips']] if request else row, ensure_ascii=False).encode() + if route.startswith('redirect'): + target = '/echo' + self.path[len('/' + route):] + if route == 'redirect-other': + target = f'http://localhost:{foreign.server_port}{target}' + elif route == 'redirect-port': + target = f'http://127.0.0.1:{foreign.server_port}{target}' + elif route == 'redirect-user': + target = f'http://user:pass@127.0.0.1:{self.server.server_port}{target}' + elif route == 'redirect-loop': + target = self.path + self.send_response(307) + self.send_header('Location', target) + redirect_body = b'x' * 2048 if route == 'redirect-body' else b'' + self.send_header('Content-Length', str(len(redirect_body))) + self.end_headers() + self.wfile.write(redirect_body) + return + if route == 'headers-delay': + time.sleep(0.35) + status = 429 if route.startswith('error-') else 200 + if route in ('chunked-cut', 'error-chunked-cut'): + self.protocol_version = 'HTTP/1.1' + self.send_response(status) + self.send_header('Transfer-Encoding', 'chunked') + self.send_header('Connection', 'close') + self.end_headers() + self.wfile.write(f'{len(body):x}\r\n'.encode() + body + b'\r\n') + self.close_connection = True + return + encoding = None + if route in ('gzip', 'bad-gzip', 'gzip-cut', 'error-gzip-cut', 'gzip-big', 'gzip-progress'): + encoding = 'gzip' + if route == 'gzip-big': + body = b'x' * (2 * 1024 * 1024) + body = gzip.compress(body) + if route in ('gzip-cut', 'error-gzip-cut'): + body = body[:-5] + if route == 'bad-gzip': + body = b'not a gzip stream' + if route == 'deflate': + encoding = 'deflate' + body = zlib.compress(body) + if route == 'br': + encoding = 'br' + body = bytes.fromhex('8b07807b226970223a22382e382e382e38227d03') + if route == 'request-headers': + body = json.dumps(dict(self.headers)).encode() + if route == 'large': + row = json.dumps({'ip': '8.8.8.8', 'location': {'country_name': 'مصر', 'city': 'x' * 2048}}, ensure_ascii=False).encode() + self.send_response(200) + self.send_header('Content-Length', str(50000 * (len(row) + 1) + 1)) + self.end_headers() + self.wfile.write(b'[') + for i in range(50000): + self.wfile.write((b',' if i else b'') + row) + self.wfile.write(b']') + return + if route == 'error-large': + body = b'x' * 4096 + if route == 'trickle': + body = b'{}\n' + self.send_response(status) + self.send_header('Content-Length', str(len(body) + (20 if route in ('cut', 'error-cut') else 5 if route in ('gzip-cut', 'error-gzip-cut') else 0))) + if encoding: + self.send_header('Content-Encoding', encoding) + self.end_headers() + self.wfile.flush() + if route in ('body-delay', 'error-delay'): + time.sleep(0.35) + if route == 'body-stall': + self.wfile.write(body[:2]) + self.wfile.flush() + time.sleep(0.35) + self.wfile.write(body[2:]) + return + if route == 'trickle': + for i in range(len(body)): + if i: + time.sleep(1.5) + self.wfile.write(body[i:i + 1]) + self.wfile.flush() + elif route == 'gzip-progress': + for i in range(0, len(body), 2): + self.wfile.write(body[i:i + 2]) + self.wfile.flush() + time.sleep(0.02) + elif route == 'progress': + for i in range(0, len(body), 4): + self.wfile.write(body[i:i + 4]) + self.wfile.flush() + time.sleep(0.02) + else: + self.wfile.write(body) + + +with ThreadingHTTPServer(('127.0.0.1', 0), Handler) as origin, ThreadingHTTPServer(('127.0.0.1', 0), Handler) as foreign: + for server in (origin, foreign): + threading.Thread(target=server.serve_forever, daemon=True).start() + print(json.dumps({'origin': origin.server_port, 'foreign': foreign.server_port}), flush=True) + threading.Event().wait()