From c462f4ba5a6e2626bda14c069edd888706d1e730 Mon Sep 17 00:00:00 2001 From: Guido Zuidhof Date: Tue, 17 Feb 2026 12:02:50 +0100 Subject: [PATCH 1/7] Add support for Risk Intelligence, simplify API endpoint config --- README.md | 2 +- examples/form/README.md | 2 +- examples/form/index.php | 10 +- src/client.php | 10 +- src/config.php | 60 ++++ src/response.php | 26 ++ src/risk_intelligence.php | 590 ++++++++++++++++++++++++++++++++++++++ tests/configTest.php | 123 ++++++++ tests/verifyTest.php | 83 +++++- 9 files changed, 880 insertions(+), 26 deletions(-) create mode 100644 src/risk_intelligence.php create mode 100644 tests/configTest.php diff --git a/README.md b/README.md index f5a83f6..8a1b159 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ $config = new ClientConfig(); $config->setAPIKey("")->setSitekey(""); // You can also specify which endpoint to use, for example `"global"` or `"eu"`. -// $config->setEndpoint("eu") +// $config->setApiEndpoint("eu") $captchaClient = new Client($config) ``` diff --git a/examples/form/README.md b/examples/form/README.md index e3463f9..b9f5276 100644 --- a/examples/form/README.md +++ b/examples/form/README.md @@ -11,7 +11,7 @@ FRC_APIKEY=YOUR_API_KEY FRC_SITEKEY=YOUR_SITEKEY php -S localhost:8000 Alternatively, you can also specify custom endpoints: ```shell -FRC_SITEKEY=YOUR_API_KEY FRC_APIKEY=YOUR_SITEKEY FRC_SITEVERIFY_ENDPOINT=https://eu-dev.frcapi.com/api/v2/captcha/siteverify FRC_WIDGET_ENDPOINT=https://eu-dev.frcapi.com/api/v2/captcha php -S localhost:8000 +FRC_SITEKEY=YOUR_API_KEY FRC_APIKEY=YOUR_SITEKEY FRC_API_ENDPOINT=https://eu.frcapi.com/ FRC_WIDGET_ENDPOINT=https://eu.frcapi.com/api/v2/captcha php -S localhost:8000 ``` Now open your browser and navigate to [http://localhost:8000](http://localhost:8000). diff --git a/examples/form/index.php b/examples/form/index.php index c74289f..652ca67 100644 --- a/examples/form/index.php +++ b/examples/form/index.php @@ -10,11 +10,11 @@ $apikey = getenv('FRC_APIKEY'); // Optionally we can pass in custom endpoints to be used, such as "eu". -$siteverifyEndpoint = getenv('FRC_SITEVERIFY_ENDPOINT'); +$apiEndpoint = getenv('FRC_API_ENDPOINT'); $widgetEndpoint = getenv('FRC_WIDGET_ENDPOINT'); -const MODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.8/site.min.js"; -const NOMODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.8/site.compat.min.js";; // Compatibility fallback for old browsers. +const MODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.37/site.min.js"; +const NOMODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.37/site.compat.min.js"; // Compatibility fallback for old browsers. if (empty($sitekey) || empty($apikey)) { die("Please set the FRC_SITEKEY and FRC_APIKEY environment values before running this example."); @@ -55,8 +55,8 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey) $config = new \FriendlyCaptcha\SDK\ClientConfig(); $config->setAPIKey($apikey); $config->setSitekey($sitekey); -if (!empty($siteverifyEndpoint)) { - $config->setSiteverifyEndpoint($siteverifyEndpoint); // Optional, it defaults to "global". +if (!empty($apiEndpoint)) { + $config->setApiEndpoint($apiEndpoint); // Optional, it defaults to "global". Use base URL without path. } $frcClient = new \FriendlyCaptcha\SDK\Client($config); diff --git a/src/client.php b/src/client.php index efc8202..b74db52 100644 --- a/src/client.php +++ b/src/client.php @@ -7,8 +7,9 @@ use FriendlyCaptcha\SDK\{ClientConfig, VerifyResult, ErrorCodes}; const VERSION = "0.1.2"; -const EU_API_ENDPOINT = "https://eu.frcapi.com/api/v2/captcha/siteverify"; -const GLOBAL_API_ENDPOINT = "https://global.frcapi.com/api/v2/captcha/siteverify"; +const EU_API_ENDPOINT = "https://eu.frcapi.com"; +const GLOBAL_API_ENDPOINT = "https://global.frcapi.com"; +const SITEVERIFY_PATH = "/api/v2/captcha/siteverify"; class Client { @@ -28,7 +29,8 @@ public function __construct(ClientConfig $config) throw new \Exception("API key is required"); } - $endpoint = $this->config->siteverifyEndpoint; + // Use apiEndpoint (preferred) or fall back to siteverifyEndpoint for backwards compatibility + $endpoint = $this->config->apiEndpoint; if ($endpoint === "eu") { $endpoint = EU_API_ENDPOINT; @@ -36,7 +38,7 @@ public function __construct(ClientConfig $config) $endpoint = GLOBAL_API_ENDPOINT; } - $this->resolvedSiteverifyEndpoint = $endpoint; + $this->resolvedSiteverifyEndpoint = $endpoint . SITEVERIFY_PATH; } public function verifyCaptchaResponse(?string $response): VerifyResult diff --git a/src/config.php b/src/config.php index 748c4a4..ffd873e 100644 --- a/src/config.php +++ b/src/config.php @@ -11,7 +11,17 @@ class ClientConfig public $apiKey = ""; public $sitekey = ""; public $sdkTrailer = ""; + + /** + * @deprecated Use apiEndpoint instead. This field will be removed in a future version. + */ public $siteverifyEndpoint = "global"; + + /** + * The API endpoint URL without path. Accepts shorthands "global" or "eu", or a base URL like "https://api.example.com". + */ + public $apiEndpoint = "global"; + public $strict = false; public $timeout = 30; public $connectTimeout = 20; @@ -54,6 +64,7 @@ public function setSDKTrailer(string $sdkTrailer): self } /** + * @deprecated Use setApiEndpoint instead. This method will be removed in a future version. * @param string $siteverifyEndpoint a full URL, or the shorthands `"global"` or `"eu"`. */ public function setSiteverifyEndpoint(string $siteverifyEndpoint): self @@ -61,7 +72,56 @@ public function setSiteverifyEndpoint(string $siteverifyEndpoint): self if ($siteverifyEndpoint != "global" && $siteverifyEndpoint != "eu" && substr($siteverifyEndpoint, 0, 4) != "http") { throw new Exception("Invalid argument '" . $siteverifyEndpoint . "' to setSiteverifyEndpoint, it must be a full URL or one of the shorthands 'global' or 'eu'."); } + + // Strip the path from the URL if it's a full URL + if (substr($siteverifyEndpoint, 0, 4) == "http") { + $parsed = parse_url($siteverifyEndpoint); + if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) { + throw new Exception("Invalid URL '" . $siteverifyEndpoint . "' provided to setSiteverifyEndpoint."); + } + $siteverifyEndpoint = $parsed['scheme'] . '://' . $parsed['host']; + if (isset($parsed['port'])) { + $siteverifyEndpoint .= ':' . $parsed['port']; + } + } + $this->siteverifyEndpoint = $siteverifyEndpoint; + $this->apiEndpoint = $siteverifyEndpoint; + return $this; + } + + /** + * Set the API endpoint URL without path. + * + * @param string $apiEndpoint Base URL without path (e.g., "https://api.example.com") or shorthands "global" or "eu". + */ + public function setApiEndpoint(string $apiEndpoint): self + { + if ($apiEndpoint != "global" && $apiEndpoint != "eu" && substr($apiEndpoint, 0, 4) != "http") { + throw new Exception("Invalid argument '" . $apiEndpoint . "' to setApiEndpoint, it must be a base URL (without path) or one of the shorthands 'global' or 'eu'."); + } + + // Validate that it's a base URL without path + if (substr($apiEndpoint, 0, 4) == "http") { + $parsed = parse_url($apiEndpoint); + if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) { + throw new Exception("Invalid URL '" . $apiEndpoint . "' provided to setApiEndpoint."); + } + + // Check if path is present and not just '/' + if (isset($parsed['path']) && $parsed['path'] !== '' && $parsed['path'] !== '/') { + throw new Exception("API endpoint should not include a path. Got: '" . $apiEndpoint . "'. Please use the base URL only (e.g., 'https://api.example.com')."); + } + + // Reconstruct URL without path + $apiEndpoint = $parsed['scheme'] . '://' . $parsed['host']; + if (isset($parsed['port'])) { + $apiEndpoint .= ':' . $parsed['port']; + } + } + + $this->apiEndpoint = $apiEndpoint; + $this->siteverifyEndpoint = $apiEndpoint; return $this; } diff --git a/src/response.php b/src/response.php index 9a691cd..acb3aa7 100644 --- a/src/response.php +++ b/src/response.php @@ -5,6 +5,7 @@ namespace FriendlyCaptcha\SDK; use DateTimeImmutable; +use FriendlyCaptcha\SDK\RiskIntelligence; class VerifyResponseChallengeData { @@ -36,6 +37,8 @@ public static function fromStdClass($obj): VerifyResponseChallengeData class VerifyResponseData { + /** @var string|null */ + public $event_id; /** @var VerifyResponseChallengeData */ public $challenge; @@ -46,6 +49,7 @@ public static function fromJson($json): ?VerifyResponseData return null; } $instance = new self(); + $instance->event_id = $data->event_id ?? null; $instance->challenge = VerifyResponseChallengeData::fromStdClass($data->challenge); return $instance; } @@ -53,6 +57,7 @@ public static function fromJson($json): ?VerifyResponseData public static function fromStdClass($obj): VerifyResponseData { $instance = new self(); + $instance->event_id = $obj->event_id ?? null; $instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge); return $instance; } @@ -94,6 +99,10 @@ class VerifyResponse public $data; /** @var VerifyResponseError|null */ public $error; + /** @var RiskIntelligence|null */ + public $risk_intelligence; + /** @var object|null Raw untyped risk intelligence data */ + private $risk_intelligence_raw; public static function fromJson($json): ?VerifyResponse { @@ -117,6 +126,23 @@ public static function fromJson($json): ?VerifyResponse $instance->error = VerifyResponseError::fromStdClass($d->error); } + if (isset($d->risk_intelligence)) { + $instance->risk_intelligence_raw = $d->risk_intelligence; + $instance->risk_intelligence = RiskIntelligence::fromStdClass($d->risk_intelligence); + } + return $instance; } + + /** + * Get the raw risk intelligence data as an untyped object. + * This can be useful when you need access to the data in its original form + * or when new fields are added that aren't yet supported by the typed API. + * + * @return object|null The raw risk intelligence data, or null if not present + */ + public function getRawRiskIntelligence(): ?object + { + return $this->risk_intelligence_raw; + } } diff --git a/src/risk_intelligence.php b/src/risk_intelligence.php new file mode 100644 index 0000000..d8ed1b8 --- /dev/null +++ b/src/risk_intelligence.php @@ -0,0 +1,590 @@ +overall = $obj->overall; + $instance->network = $obj->network; + $instance->browser = $obj->browser; + return $instance; + } +} + +/** + * Information about the Autonomous System associated with an IP address. + */ +class NetworkAS +{ + /** @var int Autonomous System Number */ + public $number; + + /** @var string AS name */ + public $name; + + /** @var string Company name */ + public $company; + + /** @var string Description */ + public $description; + + /** @var string Domain */ + public $domain; + + /** @var string Country code */ + public $country; + + /** @var string Regional Internet Registry */ + public $rir; + + /** @var string Route */ + public $route; + + /** @var string Type */ + public $type; + + public static function fromStdClass($obj): NetworkAS + { + $instance = new self(); + $instance->number = $obj->number; + $instance->name = $obj->name; + $instance->company = $obj->company; + $instance->description = $obj->description; + $instance->domain = $obj->domain; + $instance->country = $obj->country; + $instance->rir = $obj->rir; + $instance->route = $obj->route; + $instance->type = $obj->type; + return $instance; + } +} + +/** + * Country information. + */ +class NetworkGeolocationCountry +{ + /** @var string Two-letter ISO 3166-1 alpha-2 country code */ + public $iso2; + + /** @var string Three-letter ISO 3166-1 alpha-3 country code */ + public $iso3; + + /** @var string English name of the country */ + public $name; + + /** @var string Native name of the country */ + public $name_native; + + /** @var string Geographic region */ + public $region; + + /** @var string Geographic subregion */ + public $subregion; + + /** @var string Currency code */ + public $currency; + + /** @var string Currency name */ + public $currency_name; + + /** @var string International dialing code */ + public $phone_code; + + /** @var string Capital city */ + public $capital; + + public static function fromStdClass($obj): NetworkGeolocationCountry + { + $instance = new self(); + $instance->iso2 = $obj->iso2; + $instance->iso3 = $obj->iso3; + $instance->name = $obj->name; + $instance->name_native = $obj->name_native; + $instance->region = $obj->region; + $instance->subregion = $obj->subregion; + $instance->currency = $obj->currency; + $instance->currency_name = $obj->currency_name; + $instance->phone_code = $obj->phone_code; + $instance->capital = $obj->capital; + return $instance; + } +} + +/** + * Geographic information about an IP address. + */ +class NetworkGeolocation +{ + /** @var NetworkGeolocationCountry Country information */ + public $country; + + /** @var string City name (empty string if unknown) */ + public $city; + + /** @var string State/region/province (empty string if unknown) */ + public $state; + + public static function fromStdClass($obj): NetworkGeolocation + { + $instance = new self(); + $instance->country = NetworkGeolocationCountry::fromStdClass($obj->country); + $instance->city = $obj->city; + $instance->state = $obj->state; + return $instance; + } +} + +/** + * Abuse contact information for reporting network abuse. + */ +class NetworkAbuseContact +{ + /** @var string Postal address of the abuse contact */ + public $address; + + /** @var string Name of the abuse contact person or team */ + public $name; + + /** @var string Abuse contact email address */ + public $email; + + /** @var string Abuse contact phone number */ + public $phone; + + public static function fromStdClass($obj): NetworkAbuseContact + { + $instance = new self(); + $instance->address = $obj->address; + $instance->name = $obj->name; + $instance->email = $obj->email; + $instance->phone = $obj->phone; + return $instance; + } +} + +/** + * IP anonymization and privacy information. + */ +class NetworkAnonymization +{ + /** @var int Likelihood that the IP is from a VPN service (0-5) */ + public $vpn_score; + + /** @var int Likelihood that the IP is from a proxy service (0-5) */ + public $proxy_score; + + /** @var bool Whether the IP is a Tor exit node */ + public $tor; + + /** @var bool Whether the IP is from iCloud Private Relay */ + public $icloud_private_relay; + + public static function fromStdClass($obj): NetworkAnonymization + { + $instance = new self(); + $instance->vpn_score = $obj->vpn_score; + $instance->proxy_score = $obj->proxy_score; + $instance->tor = $obj->tor; + $instance->icloud_private_relay = $obj->icloud_private_relay; + return $instance; + } +} + +/** + * Network and IP information. + */ +class Network +{ + /** + * The IP address of the user when the risk intelligence data was gathered. + * Note: The IP address is never stored on Friendly Captcha servers in an unhashed format. + * @var string + */ + public $ip; + + /** @var NetworkAS|null Autonomous System information (null when IP Intelligence module not enabled) */ + public $as; + + /** @var NetworkGeolocation|null Geographic information (null when IP Intelligence module not enabled) */ + public $geolocation; + + /** @var NetworkAbuseContact|null Abuse contact information (null when IP Intelligence module not enabled) */ + public $abuse_contact; + + /** @var NetworkAnonymization|null Anonymization service detection (null when Anonymization Detection module not enabled) */ + public $anonymization; + + public static function fromStdClass($obj): Network + { + $instance = new self(); + $instance->ip = $obj->ip; + $instance->as = isset($obj->as) ? NetworkAS::fromStdClass($obj->as) : null; + $instance->geolocation = isset($obj->geolocation) ? NetworkGeolocation::fromStdClass($obj->geolocation) : null; + $instance->abuse_contact = isset($obj->abuse_contact) ? NetworkAbuseContact::fromStdClass($obj->abuse_contact) : null; + $instance->anonymization = isset($obj->anonymization) ? NetworkAnonymization::fromStdClass($obj->anonymization) : null; + return $instance; + } +} + +/** + * Time zone information from the browser. + */ +class ClientTimeZone +{ + /** @var string IANA time zone name */ + public $name; + + /** @var string Two-letter ISO 3166-1 alpha-2 country code derived from the time zone */ + public $country_iso2; + + public static function fromStdClass($obj): ClientTimeZone + { + $instance = new self(); + $instance->name = $obj->name; + $instance->country_iso2 = $obj->country_iso2; + return $instance; + } +} + +/** + * Detected browser information. + */ +class ClientBrowser +{ + /** @var string Browser ID (empty string if unknown) */ + public $id; + + /** @var string Browser name (empty string if unknown) */ + public $name; + + /** @var string Browser version (empty string if unknown) */ + public $version; + + /** @var string Release date in YYYY-MM-DD format (empty string if unknown) */ + public $release_date; + + public static function fromStdClass($obj): ClientBrowser + { + $instance = new self(); + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->version = $obj->version; + $instance->release_date = $obj->release_date; + return $instance; + } +} + +/** + * Browser engine (rendering engine) information. + */ +class ClientBrowserEngine +{ + /** @var string Engine ID (empty string if unknown) */ + public $id; + + /** @var string Engine name (empty string if unknown) */ + public $name; + + /** @var string Engine version (empty string if unknown) */ + public $version; + + public static function fromStdClass($obj): ClientBrowserEngine + { + $instance = new self(); + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->version = $obj->version; + return $instance; + } +} + +/** + * Device type and screen information. + */ +class ClientDevice +{ + /** @var string Device type */ + public $type; + + /** @var string Device brand */ + public $brand; + + /** @var string Device model */ + public $model; + + public static function fromStdClass($obj): ClientDevice + { + $instance = new self(); + $instance->type = $obj->type; + $instance->brand = $obj->brand; + $instance->model = $obj->model; + return $instance; + } +} + +/** + * Operating system information. + */ +class ClientOS +{ + /** @var string OS ID (empty string if unknown) */ + public $id; + + /** @var string OS name (empty string if unknown) */ + public $name; + + /** @var string OS version */ + public $version; + + public static function fromStdClass($obj): ClientOS + { + $instance = new self(); + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->version = $obj->version; + return $instance; + } +} + +/** + * TLS/SSL signature information for client fingerprinting. + */ +class ClientTLSSignature +{ + /** @var string JA3 hash */ + public $ja3; + + /** @var string JA3N hash */ + public $ja3n; + + /** @var string JA4 signature */ + public $ja4; + + public static function fromStdClass($obj): ClientTLSSignature + { + $instance = new self(); + $instance->ja3 = $obj->ja3; + $instance->ja3n = $obj->ja3n; + $instance->ja4 = $obj->ja4; + return $instance; + } +} + +/** + * Known bot information. + */ +class ClientAutomationKnownBot +{ + /** @var bool Whether a known bot was detected */ + public $detected; + + /** @var string Bot identifier (empty if not detected) */ + public $id; + + /** @var string Bot name (empty if not detected) */ + public $name; + + /** @var string Bot type classification (empty if not detected) */ + public $type; + + /** @var string Link to bot documentation (empty if not detected) */ + public $url; + + public static function fromStdClass($obj): ClientAutomationKnownBot + { + $instance = new self(); + $instance->detected = $obj->detected; + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->type = $obj->type; + $instance->url = $obj->url; + return $instance; + } +} + +/** + * Automation tool information. + */ +class ClientAutomationTool +{ + /** @var bool Whether an automation tool was detected */ + public $detected; + + /** @var string Tool identifier (empty if not detected) */ + public $id; + + /** @var string Tool name (empty if not detected) */ + public $name; + + /** @var string Tool type (empty if not detected) */ + public $type; + + public static function fromStdClass($obj): ClientAutomationTool + { + $instance = new self(); + $instance->detected = $obj->detected; + $instance->id = $obj->id; + $instance->name = $obj->name; + $instance->type = $obj->type; + return $instance; + } +} + +/** + * Automation and bot detection data. + */ +class ClientAutomation +{ + /** @var ClientAutomationTool Detected automation tool information */ + public $automation_tool; + + /** @var ClientAutomationKnownBot Detected known bot information */ + public $known_bot; + + public static function fromStdClass($obj): ClientAutomation + { + $instance = new self(); + $instance->automation_tool = ClientAutomationTool::fromStdClass($obj->automation_tool); + $instance->known_bot = ClientAutomationKnownBot::fromStdClass($obj->known_bot); + return $instance; + } +} + +/** + * Client/device risk intelligence. + */ +class RiskIntelligenceClient +{ + /** @var string User-Agent HTTP header value */ + public $header_user_agent; + + /** @var ClientTimeZone|null Time zone information (null when Browser Identification module not enabled) */ + public $time_zone; + + /** @var ClientBrowser|null Browser information (null when Browser Identification module not enabled) */ + public $browser; + + /** @var ClientBrowserEngine|null Browser engine information (null when Browser Identification module not enabled) */ + public $browser_engine; + + /** @var ClientDevice|null Device information (null when Browser Identification module not enabled) */ + public $device; + + /** @var ClientOS|null Operating system information (null when Browser Identification module not enabled) */ + public $os; + + /** @var ClientTLSSignature|null TLS signature information (null when Bot Detection module not enabled) */ + public $tls_signature; + + /** @var ClientAutomation|null Automation detection data (null when Bot Detection module not enabled) */ + public $automation; + + public static function fromStdClass($obj): RiskIntelligenceClient + { + $instance = new self(); + $instance->header_user_agent = $obj->header_user_agent; + $instance->time_zone = isset($obj->time_zone) ? ClientTimeZone::fromStdClass($obj->time_zone) : null; + $instance->browser = isset($obj->browser) ? ClientBrowser::fromStdClass($obj->browser) : null; + $instance->browser_engine = isset($obj->browser_engine) ? ClientBrowserEngine::fromStdClass($obj->browser_engine) : null; + $instance->device = isset($obj->device) ? ClientDevice::fromStdClass($obj->device) : null; + $instance->os = isset($obj->os) ? ClientOS::fromStdClass($obj->os) : null; + $instance->tls_signature = isset($obj->tls_signature) ? ClientTLSSignature::fromStdClass($obj->tls_signature) : null; + $instance->automation = isset($obj->automation) ? ClientAutomation::fromStdClass($obj->automation) : null; + return $instance; + } +} + +/** + * Risk Intelligence data providing comprehensive risk assessment and signals. + * + * The Risk Intelligence data is organized into three high-level sections: + * - risk_scores: Overall risk scores summarizing the assessment (0-5 scale, null when module not enabled) + * - network: Information about the user's network and IP address + * - client: Detected browser or bot information + * + * Note: The structure may vary depending on which modules are enabled on your account. + * The format is subject to change with new fields potentially being added in the future. + */ +class RiskIntelligence +{ + /** @var RiskScores|null Risk assessment scores (null when Risk Scores module not enabled) */ + public $risk_scores; + + /** @var Network Network and IP information */ + public $network; + + /** @var RiskIntelligenceClient User agent and device information */ + public $client; + + public static function fromStdClass($obj): RiskIntelligence + { + $instance = new self(); + $instance->risk_scores = isset($obj->risk_scores) ? RiskScores::fromStdClass($obj->risk_scores) : null; + $instance->network = Network::fromStdClass($obj->network); + $instance->client = RiskIntelligenceClient::fromStdClass($obj->client); + return $instance; + } +} diff --git a/tests/configTest.php b/tests/configTest.php new file mode 100644 index 0000000..9fad5de --- /dev/null +++ b/tests/configTest.php @@ -0,0 +1,123 @@ +expectException(Exception::class); + $this->expectExceptionMessage("API key is required"); + $opts = new ClientConfig(); + $client = new Client($opts); + } + + public function testConfigInvalidEndpointThrows(): void + { + $this->expectException(Exception::class); + $opts = new ClientConfig(); + $opts->setSiteverifyEndpoint("something-invalid-that-is-not-a-url"); + } + + public function testApiEndpointWithPathThrows(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("API endpoint should not include a path"); + $opts = new ClientConfig(); + $opts->setApiEndpoint("https://api.example.com/api/v2/captcha/siteverify"); + } + + public function testApiEndpointAcceptsBaseUrl(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + $opts->setApiEndpoint("https://api.example.com"); + $this->assertEquals("https://api.example.com", $opts->apiEndpoint); + } + + public function testApiEndpointAcceptsBaseUrlWithPort(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + $opts->setApiEndpoint("https://api.example.com:8080"); + $this->assertEquals("https://api.example.com:8080", $opts->apiEndpoint); + } + + public function testApiEndpointStripsTrailingSlash(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + $opts->setApiEndpoint("https://api.example.com/"); + $this->assertEquals("https://api.example.com", $opts->apiEndpoint); + } + + public function testApiEndpointAcceptsShorthands(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + + $opts->setApiEndpoint("eu"); + $this->assertEquals("eu", $opts->apiEndpoint); + + $opts->setApiEndpoint("global"); + $this->assertEquals("global", $opts->apiEndpoint); + } + + public function testSiteverifyEndpointStripsPath(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + $opts->setSiteverifyEndpoint("https://api.example.com/api/v2/captcha/siteverify"); + $this->assertEquals("https://api.example.com", $opts->siteverifyEndpoint); + $this->assertEquals("https://api.example.com", $opts->apiEndpoint); + } + + public function testSiteverifyEndpointStripsPathWithPort(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + $opts->setSiteverifyEndpoint("http://localhost:9999/some/path/here"); + $this->assertEquals("http://localhost:9999", $opts->siteverifyEndpoint); + $this->assertEquals("http://localhost:9999", $opts->apiEndpoint); + } + + public function testClientUsesApiEndpoint(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + $opts->setApiEndpoint("http://localhost:1090"); + $client = new Client($opts); + + // The client should construct the full URL with the path + $reflection = new \ReflectionClass($client); + $property = $reflection->getProperty('resolvedSiteverifyEndpoint'); + $property->setAccessible(true); + $resolved = $property->getValue($client); + + $this->assertEquals("http://localhost:1090/api/v2/captcha/siteverify", $resolved); + } + + public function testBackwardsCompatibilityWithSiteverifyEndpointFullPath(): void + { + $opts = new ClientConfig(); + $opts->setAPIKey("test-key"); + // Old usage: passing full URL with path + $opts->setSiteverifyEndpoint("http://localhost:1090/api/v2/captcha/siteverify"); + $client = new Client($opts); + + // Should strip path and reconstruct correctly + $reflection = new \ReflectionClass($client); + $property = $reflection->getProperty('resolvedSiteverifyEndpoint'); + $property->setAccessible(true); + $resolved = $property->getValue($client); + + $this->assertEquals("http://localhost:1090/api/v2/captcha/siteverify", $resolved); + } +} diff --git a/tests/verifyTest.php b/tests/verifyTest.php index b55af37..82610e6 100644 --- a/tests/verifyTest.php +++ b/tests/verifyTest.php @@ -29,20 +29,6 @@ function loadSDKTestsFromServer(string $serverURL) final class VerifyTest extends TestCase { - public function testConfigWithoutAPIKeyThrows(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessage("API key is required"); - $opts = new ClientConfig(); - $client = new Client($opts); - } - public function testConfigInvalidEndpointThrows(): void - { - $this->expectException(Exception::class); - $opts = new ClientConfig(); - $opts->setSiteverifyEndpoint("something-invalid-that-is-not-a-url"); - } - public function testNonEncodeableResponse(): void { $opts = new ClientConfig(); @@ -91,7 +77,7 @@ public static function sdkMockTestsProvider(): array public function testSDKTestServerCase($test): void { $opts = new ClientConfig(); - $opts->setAPIKey("some-key")->setSiteverifyEndpoint(MOCK_SERVER_URL . "/api/v2/captcha/siteverify")->setStrict($test["strict"]); // Assuming there's nothing running on that port.. + $opts->setAPIKey("some-key")->setApiEndpoint(MOCK_SERVER_URL)->setStrict($test["strict"]); $client = new Client($opts); $result = $client->verifyCaptchaResponse($test["response"]); @@ -121,5 +107,72 @@ public function testSDKTestServerCase($test): void $this->assertTrue($result->shouldAccept(), "non-strict mode should accept when not able to verify"); } } + + // Additional checks for successful responses + if ($result->getResponse()->success && isset($test['siteverify_response'])) { + $expectedResponse = json_decode($test['siteverify_response']); + $this->assertNotNull($expectedResponse, "Failed to decode expected siteverify response"); + + $response = $result->getResponse(); + + // Check event_id if present + if (isset($expectedResponse->data->event_id)) { + $this->assertEquals( + $expectedResponse->data->event_id, + $response->data->event_id ?? null, + "Event ID does not match expected value" + ); + } + + // Check challenge data + if (isset($expectedResponse->data->challenge)) { + $this->assertEquals( + $expectedResponse->data->challenge->timestamp, + $response->data->challenge->timestamp->format('c'), + "Challenge timestamp does not match expected value" + ); + $this->assertEquals( + $expectedResponse->data->challenge->origin, + $response->data->challenge->origin, + "Challenge origin does not match expected value" + ); + } + + // Check risk intelligence data if present + if (isset($expectedResponse->data->risk_intelligence)) { + $this->assertNotNull( + $response->risk_intelligence, + "Risk Intelligence data should be present" + ); + + // Check specific fields: header_user_agent + if (isset($expectedResponse->data->risk_intelligence->client->header_user_agent)) { + $this->assertEquals( + $expectedResponse->data->risk_intelligence->client->header_user_agent, + $response->risk_intelligence->client->header_user_agent, + "Risk Intelligence header_user_agent does not match expected value" + ); + } + + // Check specific fields: browser ID + if (isset($expectedResponse->data->risk_intelligence->client->browser->id)) { + $this->assertEquals( + $expectedResponse->data->risk_intelligence->client->browser->id, + $response->risk_intelligence->client->browser->id, + "Risk Intelligence browser ID does not match expected value" + ); + } + + // Check that raw risk intelligence contains header_user_agent + $rawRiskIntelligence = $response->getRawRiskIntelligence(); + $this->assertNotNull($rawRiskIntelligence, "Raw risk intelligence should be available"); + $rawJson = json_encode($rawRiskIntelligence); + $this->assertStringContainsString( + 'header_user_agent', + $rawJson, + "Raw risk intelligence JSON should contain 'header_user_agent'" + ); + } + } } } From 13fb2f791260bfe53228b125c7890903a766f0f0 Mon Sep 17 00:00:00 2001 From: Guido Zuidhof Date: Tue, 17 Feb 2026 12:17:06 +0100 Subject: [PATCH 2/7] Fixes to test expectations and JSON parsing --- src/response.php | 19 ++++++++++--------- tests/verifyTest.php | 29 ++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/response.php b/src/response.php index acb3aa7..0f3a9db 100644 --- a/src/response.php +++ b/src/response.php @@ -21,7 +21,7 @@ public static function fromJson($json): ?VerifyResponseChallengeData return null; } $instance = new self(); - $instance->timestamp = DateTimeImmutable::createFromFormat("c", $data->timestamp); + $instance->timestamp = new DateTimeImmutable($data->timestamp); $instance->origin = $data->origin; return $instance; } @@ -29,7 +29,7 @@ public static function fromJson($json): ?VerifyResponseChallengeData public static function fromStdClass($obj): VerifyResponseChallengeData { $instance = new self(); - $instance->timestamp = DateTimeImmutable::createFromFormat("c", $obj->timestamp); + $instance->timestamp = new DateTimeImmutable($obj->timestamp); $instance->origin = $obj->origin; return $instance; } @@ -37,7 +37,7 @@ public static function fromStdClass($obj): VerifyResponseChallengeData class VerifyResponseData { - /** @var string|null */ + /** @var string */ public $event_id; /** @var VerifyResponseChallengeData */ public $challenge; @@ -49,7 +49,7 @@ public static function fromJson($json): ?VerifyResponseData return null; } $instance = new self(); - $instance->event_id = $data->event_id ?? null; + $instance->event_id = $data->event_id; $instance->challenge = VerifyResponseChallengeData::fromStdClass($data->challenge); return $instance; } @@ -120,17 +120,18 @@ public static function fromJson($json): ?VerifyResponse if (isset($d->data)) { $instance->data = VerifyResponseData::fromStdClass($d->data); + + // risk_intelligence is part of the data object in the API response + if (isset($d->data->risk_intelligence)) { + $instance->risk_intelligence_raw = $d->data->risk_intelligence; + $instance->risk_intelligence = RiskIntelligence::fromStdClass($d->data->risk_intelligence); + } } if (isset($d->error)) { $instance->error = VerifyResponseError::fromStdClass($d->error); } - if (isset($d->risk_intelligence)) { - $instance->risk_intelligence_raw = $d->risk_intelligence; - $instance->risk_intelligence = RiskIntelligence::fromStdClass($d->risk_intelligence); - } - return $instance; } diff --git a/tests/verifyTest.php b/tests/verifyTest.php index 82610e6..609ef65 100644 --- a/tests/verifyTest.php +++ b/tests/verifyTest.php @@ -110,8 +110,14 @@ public function testSDKTestServerCase($test): void // Additional checks for successful responses if ($result->getResponse()->success && isset($test['siteverify_response'])) { - $expectedResponse = json_decode($test['siteverify_response']); - $this->assertNotNull($expectedResponse, "Failed to decode expected siteverify response"); + // The test data might already be an array or it might be a JSON string + if (is_string($test['siteverify_response'])) { + $expectedResponse = json_decode($test['siteverify_response']); + $this->assertNotNull($expectedResponse, "Failed to decode expected siteverify response"); + } else { + // Already decoded, convert array to object for consistent access + $expectedResponse = json_decode(json_encode($test['siteverify_response'])); + } $response = $result->getResponse(); @@ -126,11 +132,21 @@ public function testSDKTestServerCase($test): void // Check challenge data if (isset($expectedResponse->data->challenge)) { + // Verify timestamp is properly parsed as DateTimeImmutable + $this->assertInstanceOf( + \DateTimeImmutable::class, + $response->data->challenge->timestamp, + "Challenge timestamp should be a DateTimeImmutable object" + ); + + // Compare timestamps by converting both to Unix timestamps + $expectedTimestamp = new \DateTimeImmutable($expectedResponse->data->challenge->timestamp); $this->assertEquals( - $expectedResponse->data->challenge->timestamp, - $response->data->challenge->timestamp->format('c'), + $expectedTimestamp->getTimestamp(), + $response->data->challenge->timestamp->getTimestamp(), "Challenge timestamp does not match expected value" ); + $this->assertEquals( $expectedResponse->data->challenge->origin, $response->data->challenge->origin, @@ -167,9 +183,8 @@ public function testSDKTestServerCase($test): void $rawRiskIntelligence = $response->getRawRiskIntelligence(); $this->assertNotNull($rawRiskIntelligence, "Raw risk intelligence should be available"); $rawJson = json_encode($rawRiskIntelligence); - $this->assertStringContainsString( - 'header_user_agent', - $rawJson, + $this->assertTrue( + strpos($rawJson, 'header_user_agent') !== false, "Raw risk intelligence JSON should contain 'header_user_agent'" ); } From 8fd379be730956de23e8871db26bc9d4ff655e01 Mon Sep 17 00:00:00 2001 From: Guido Zuidhof Date: Tue, 17 Feb 2026 12:24:54 +0100 Subject: [PATCH 3/7] Handle invalid responses more robustly --- src/response.php | 18 +++++- src/risk_intelligence.php | 122 +++++++++++++++++++------------------- 2 files changed, 76 insertions(+), 64 deletions(-) diff --git a/src/response.php b/src/response.php index 0f3a9db..8bdad18 100644 --- a/src/response.php +++ b/src/response.php @@ -21,7 +21,13 @@ public static function fromJson($json): ?VerifyResponseChallengeData return null; } $instance = new self(); - $instance->timestamp = new DateTimeImmutable($data->timestamp); + try { + $instance->timestamp = new DateTimeImmutable($data->timestamp); + } catch (\Exception $e) { + // This should never happen - indicates malformed API response + error_log("Failed to parse timestamp from API response: " . $e->getMessage() . ". Using Unix epoch as fallback."); + $instance->timestamp = new DateTimeImmutable('@0'); + } $instance->origin = $data->origin; return $instance; } @@ -29,7 +35,13 @@ public static function fromJson($json): ?VerifyResponseChallengeData public static function fromStdClass($obj): VerifyResponseChallengeData { $instance = new self(); - $instance->timestamp = new DateTimeImmutable($obj->timestamp); + try { + $instance->timestamp = new DateTimeImmutable($obj->timestamp); + } catch (\Exception $e) { + // This should never happen - indicates malformed API response + error_log("Failed to parse timestamp from API response: " . $e->getMessage() . ". Using Unix epoch as fallback."); + $instance->timestamp = new DateTimeImmutable('@0'); + } $instance->origin = $obj->origin; return $instance; } @@ -57,7 +69,7 @@ public static function fromJson($json): ?VerifyResponseData public static function fromStdClass($obj): VerifyResponseData { $instance = new self(); - $instance->event_id = $obj->event_id ?? null; + $instance->event_id = $obj->event_id; $instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge); return $instance; } diff --git a/src/risk_intelligence.php b/src/risk_intelligence.php index d8ed1b8..a60d03d 100644 --- a/src/risk_intelligence.php +++ b/src/risk_intelligence.php @@ -66,9 +66,9 @@ class RiskScores public static function fromStdClass($obj): RiskScores { $instance = new self(); - $instance->overall = $obj->overall; - $instance->network = $obj->network; - $instance->browser = $obj->browser; + $instance->overall = $obj->overall ?? 0; + $instance->network = $obj->network ?? 0; + $instance->browser = $obj->browser ?? 0; return $instance; } } @@ -108,15 +108,15 @@ class NetworkAS public static function fromStdClass($obj): NetworkAS { $instance = new self(); - $instance->number = $obj->number; - $instance->name = $obj->name; - $instance->company = $obj->company; - $instance->description = $obj->description; - $instance->domain = $obj->domain; - $instance->country = $obj->country; - $instance->rir = $obj->rir; - $instance->route = $obj->route; - $instance->type = $obj->type; + $instance->number = $obj->number ?? 0; + $instance->name = $obj->name ?? ''; + $instance->company = $obj->company ?? ''; + $instance->description = $obj->description ?? ''; + $instance->domain = $obj->domain ?? ''; + $instance->country = $obj->country ?? ''; + $instance->rir = $obj->rir ?? ''; + $instance->route = $obj->route ?? ''; + $instance->type = $obj->type ?? ''; return $instance; } } @@ -159,16 +159,16 @@ class NetworkGeolocationCountry public static function fromStdClass($obj): NetworkGeolocationCountry { $instance = new self(); - $instance->iso2 = $obj->iso2; - $instance->iso3 = $obj->iso3; - $instance->name = $obj->name; - $instance->name_native = $obj->name_native; - $instance->region = $obj->region; - $instance->subregion = $obj->subregion; - $instance->currency = $obj->currency; - $instance->currency_name = $obj->currency_name; - $instance->phone_code = $obj->phone_code; - $instance->capital = $obj->capital; + $instance->iso2 = $obj->iso2 ?? ''; + $instance->iso3 = $obj->iso3 ?? ''; + $instance->name = $obj->name ?? ''; + $instance->name_native = $obj->name_native ?? ''; + $instance->region = $obj->region ?? ''; + $instance->subregion = $obj->subregion ?? ''; + $instance->currency = $obj->currency ?? ''; + $instance->currency_name = $obj->currency_name ?? ''; + $instance->phone_code = $obj->phone_code ?? ''; + $instance->capital = $obj->capital ?? ''; return $instance; } } @@ -191,8 +191,8 @@ public static function fromStdClass($obj): NetworkGeolocation { $instance = new self(); $instance->country = NetworkGeolocationCountry::fromStdClass($obj->country); - $instance->city = $obj->city; - $instance->state = $obj->state; + $instance->city = $obj->city ?? ''; + $instance->state = $obj->state ?? ''; return $instance; } } @@ -217,10 +217,10 @@ class NetworkAbuseContact public static function fromStdClass($obj): NetworkAbuseContact { $instance = new self(); - $instance->address = $obj->address; - $instance->name = $obj->name; - $instance->email = $obj->email; - $instance->phone = $obj->phone; + $instance->address = $obj->address ?? ''; + $instance->name = $obj->name ?? ''; + $instance->email = $obj->email ?? ''; + $instance->phone = $obj->phone ?? ''; return $instance; } } @@ -245,10 +245,10 @@ class NetworkAnonymization public static function fromStdClass($obj): NetworkAnonymization { $instance = new self(); - $instance->vpn_score = $obj->vpn_score; - $instance->proxy_score = $obj->proxy_score; - $instance->tor = $obj->tor; - $instance->icloud_private_relay = $obj->icloud_private_relay; + $instance->vpn_score = $obj->vpn_score ?? 0; + $instance->proxy_score = $obj->proxy_score ?? 0; + $instance->tor = $obj->tor ?? false; + $instance->icloud_private_relay = $obj->icloud_private_relay ?? false; return $instance; } } @@ -280,7 +280,7 @@ class Network public static function fromStdClass($obj): Network { $instance = new self(); - $instance->ip = $obj->ip; + $instance->ip = $obj->ip ?? ''; $instance->as = isset($obj->as) ? NetworkAS::fromStdClass($obj->as) : null; $instance->geolocation = isset($obj->geolocation) ? NetworkGeolocation::fromStdClass($obj->geolocation) : null; $instance->abuse_contact = isset($obj->abuse_contact) ? NetworkAbuseContact::fromStdClass($obj->abuse_contact) : null; @@ -303,8 +303,8 @@ class ClientTimeZone public static function fromStdClass($obj): ClientTimeZone { $instance = new self(); - $instance->name = $obj->name; - $instance->country_iso2 = $obj->country_iso2; + $instance->name = $obj->name ?? ''; + $instance->country_iso2 = $obj->country_iso2 ?? ''; return $instance; } } @@ -329,10 +329,10 @@ class ClientBrowser public static function fromStdClass($obj): ClientBrowser { $instance = new self(); - $instance->id = $obj->id; - $instance->name = $obj->name; - $instance->version = $obj->version; - $instance->release_date = $obj->release_date; + $instance->id = $obj->id ?? ''; + $instance->name = $obj->name ?? ''; + $instance->version = $obj->version ?? ''; + $instance->release_date = $obj->release_date ?? ''; return $instance; } } @@ -354,9 +354,9 @@ class ClientBrowserEngine public static function fromStdClass($obj): ClientBrowserEngine { $instance = new self(); - $instance->id = $obj->id; - $instance->name = $obj->name; - $instance->version = $obj->version; + $instance->id = $obj->id ?? ''; + $instance->name = $obj->name ?? ''; + $instance->version = $obj->version ?? ''; return $instance; } } @@ -378,9 +378,9 @@ class ClientDevice public static function fromStdClass($obj): ClientDevice { $instance = new self(); - $instance->type = $obj->type; - $instance->brand = $obj->brand; - $instance->model = $obj->model; + $instance->type = $obj->type ?? ''; + $instance->brand = $obj->brand ?? ''; + $instance->model = $obj->model ?? ''; return $instance; } } @@ -402,9 +402,9 @@ class ClientOS public static function fromStdClass($obj): ClientOS { $instance = new self(); - $instance->id = $obj->id; - $instance->name = $obj->name; - $instance->version = $obj->version; + $instance->id = $obj->id ?? ''; + $instance->name = $obj->name ?? ''; + $instance->version = $obj->version ?? ''; return $instance; } } @@ -426,9 +426,9 @@ class ClientTLSSignature public static function fromStdClass($obj): ClientTLSSignature { $instance = new self(); - $instance->ja3 = $obj->ja3; - $instance->ja3n = $obj->ja3n; - $instance->ja4 = $obj->ja4; + $instance->ja3 = $obj->ja3 ?? ''; + $instance->ja3n = $obj->ja3n ?? ''; + $instance->ja4 = $obj->ja4 ?? ''; return $instance; } } @@ -456,11 +456,11 @@ class ClientAutomationKnownBot public static function fromStdClass($obj): ClientAutomationKnownBot { $instance = new self(); - $instance->detected = $obj->detected; - $instance->id = $obj->id; - $instance->name = $obj->name; - $instance->type = $obj->type; - $instance->url = $obj->url; + $instance->detected = $obj->detected ?? false; + $instance->id = $obj->id ?? ''; + $instance->name = $obj->name ?? ''; + $instance->type = $obj->type ?? ''; + $instance->url = $obj->url ?? ''; return $instance; } } @@ -485,10 +485,10 @@ class ClientAutomationTool public static function fromStdClass($obj): ClientAutomationTool { $instance = new self(); - $instance->detected = $obj->detected; - $instance->id = $obj->id; - $instance->name = $obj->name; - $instance->type = $obj->type; + $instance->detected = $obj->detected ?? false; + $instance->id = $obj->id ?? ''; + $instance->name = $obj->name ?? ''; + $instance->type = $obj->type ?? ''; return $instance; } } @@ -545,7 +545,7 @@ class RiskIntelligenceClient public static function fromStdClass($obj): RiskIntelligenceClient { $instance = new self(); - $instance->header_user_agent = $obj->header_user_agent; + $instance->header_user_agent = $obj->header_user_agent ?? ''; $instance->time_zone = isset($obj->time_zone) ? ClientTimeZone::fromStdClass($obj->time_zone) : null; $instance->browser = isset($obj->browser) ? ClientBrowser::fromStdClass($obj->browser) : null; $instance->browser_engine = isset($obj->browser_engine) ? ClientBrowserEngine::fromStdClass($obj->browser_engine) : null; From 2d5e7b6dd9ddce8eb5d79833c20fb35466429cf1 Mon Sep 17 00:00:00 2001 From: Guido Zuidhof Date: Tue, 17 Feb 2026 14:05:44 +0100 Subject: [PATCH 4/7] Handle invalid data in a way that PHP 7.1 also accepts --- README.md | 12 ++++++++++++ src/response.php | 30 +++++++++++++++++++++++++----- tests/verifyTest.php | 5 ++--- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8a1b159..65cf345 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,18 @@ Time: 36 ms, Memory: 4.00 MB OK (28 tests, 110 assertions) ``` +## Alternative: use Docker +You can also use Docker to run the tests without installing PHP and Composer on your machine. Make sure you have Docker installed, then run the following command in the root of the project: + +```shell +docker run --rm -v $(pwd):/app -w /app --network host php:7.4-cli bash -c " + apt-get update -qq && + apt-get install -y -qq git unzip && + php bin/composer.phar install && + vendor/bin/phpunit +" +``` + ### Optional Install an old version of PHP (to be sure it works in that version). The oldest PHP version this SDK supports is 7.1. diff --git a/src/response.php b/src/response.php index 8bdad18..32e43f3 100644 --- a/src/response.php +++ b/src/response.php @@ -14,6 +14,18 @@ class VerifyResponseChallengeData /** @var string */ public $origin; + /** + * Create an empty fallback instance with default values. + * Used when challenge data is missing or malformed. + */ + public static function empty(): VerifyResponseChallengeData + { + $instance = new self(); + $instance->timestamp = new DateTimeImmutable('@0'); + $instance->origin = ''; + return $instance; + } + public static function fromJson($json): ?VerifyResponseChallengeData { $data = json_decode($json); @@ -62,7 +74,11 @@ public static function fromJson($json): ?VerifyResponseData } $instance = new self(); $instance->event_id = $data->event_id; - $instance->challenge = VerifyResponseChallengeData::fromStdClass($data->challenge); + if (isset($data->challenge) && is_object($data->challenge)) { + $instance->challenge = VerifyResponseChallengeData::fromStdClass($data->challenge); + } else { + $instance->challenge = VerifyResponseChallengeData::empty(); + } return $instance; } @@ -70,7 +86,11 @@ public static function fromStdClass($obj): VerifyResponseData { $instance = new self(); $instance->event_id = $obj->event_id; - $instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge); + if (isset($obj->challenge) && is_object($obj->challenge)) { + $instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge); + } else { + $instance->challenge = VerifyResponseChallengeData::empty(); + } return $instance; } } @@ -130,17 +150,17 @@ public static function fromJson($json): ?VerifyResponse } - if (isset($d->data)) { + if (isset($d->data) && is_object($d->data)) { $instance->data = VerifyResponseData::fromStdClass($d->data); // risk_intelligence is part of the data object in the API response - if (isset($d->data->risk_intelligence)) { + if (isset($d->data->risk_intelligence) && is_object($d->data->risk_intelligence)) { $instance->risk_intelligence_raw = $d->data->risk_intelligence; $instance->risk_intelligence = RiskIntelligence::fromStdClass($d->data->risk_intelligence); } } - if (isset($d->error)) { + if (isset($d->error) && is_object($d->error)) { $instance->error = VerifyResponseError::fromStdClass($d->error); } diff --git a/tests/verifyTest.php b/tests/verifyTest.php index 609ef65..eb6b18e 100644 --- a/tests/verifyTest.php +++ b/tests/verifyTest.php @@ -109,7 +109,8 @@ public function testSDKTestServerCase($test): void } // Additional checks for successful responses - if ($result->getResponse()->success && isset($test['siteverify_response'])) { + $response = $result->getResponse(); + if ($response !== null && $response->success && isset($test['siteverify_response'])) { // The test data might already be an array or it might be a JSON string if (is_string($test['siteverify_response'])) { $expectedResponse = json_decode($test['siteverify_response']); @@ -119,8 +120,6 @@ public function testSDKTestServerCase($test): void $expectedResponse = json_decode(json_encode($test['siteverify_response'])); } - $response = $result->getResponse(); - // Check event_id if present if (isset($expectedResponse->data->event_id)) { $this->assertEquals( From e8e46746c24d0d61e35a4eccfe9cbede816e3f56 Mon Sep 17 00:00:00 2001 From: Guido Zuidhof Date: Tue, 17 Feb 2026 14:11:13 +0100 Subject: [PATCH 5/7] Fix undefined access for malformed responses in 8.x --- src/risk_intelligence.php | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/risk_intelligence.php b/src/risk_intelligence.php index a60d03d..7113f5c 100644 --- a/src/risk_intelligence.php +++ b/src/risk_intelligence.php @@ -277,6 +277,21 @@ class Network /** @var NetworkAnonymization|null Anonymization service detection (null when Anonymization Detection module not enabled) */ public $anonymization; + /** + * Create an empty fallback instance with default values. + * Used when network data is missing or malformed. + */ + public static function empty(): Network + { + $instance = new self(); + $instance->ip = ''; + $instance->as = null; + $instance->geolocation = null; + $instance->abuse_contact = null; + $instance->anonymization = null; + return $instance; + } + public static function fromStdClass($obj): Network { $instance = new self(); @@ -542,6 +557,24 @@ class RiskIntelligenceClient /** @var ClientAutomation|null Automation detection data (null when Bot Detection module not enabled) */ public $automation; + /** + * Create an empty fallback instance with default values. + * Used when client data is missing or malformed. + */ + public static function empty(): RiskIntelligenceClient + { + $instance = new self(); + $instance->header_user_agent = ''; + $instance->time_zone = null; + $instance->browser = null; + $instance->browser_engine = null; + $instance->device = null; + $instance->os = null; + $instance->tls_signature = null; + $instance->automation = null; + return $instance; + } + public static function fromStdClass($obj): RiskIntelligenceClient { $instance = new self(); @@ -583,8 +616,8 @@ public static function fromStdClass($obj): RiskIntelligence { $instance = new self(); $instance->risk_scores = isset($obj->risk_scores) ? RiskScores::fromStdClass($obj->risk_scores) : null; - $instance->network = Network::fromStdClass($obj->network); - $instance->client = RiskIntelligenceClient::fromStdClass($obj->client); + $instance->network = isset($obj->network) && is_object($obj->network) ? Network::fromStdClass($obj->network) : Network::empty(); + $instance->client = isset($obj->client) && is_object($obj->client) ? RiskIntelligenceClient::fromStdClass($obj->client) : RiskIntelligenceClient::empty(); return $instance; } } From 156805f5b0d0d47095c29b1defaa267860c0fba1 Mon Sep 17 00:00:00 2001 From: Guido Zuidhof Date: Tue, 17 Feb 2026 14:16:25 +0100 Subject: [PATCH 6/7] Fix return type annotation for PHP 7.1 --- src/response.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/response.php b/src/response.php index 32e43f3..073a801 100644 --- a/src/response.php +++ b/src/response.php @@ -174,7 +174,7 @@ public static function fromJson($json): ?VerifyResponse * * @return object|null The raw risk intelligence data, or null if not present */ - public function getRawRiskIntelligence(): ?object + public function getRawRiskIntelligence() { return $this->risk_intelligence_raw; } From 55e09d67dfe9d34f3b7b8dbf527c744e4f2455c8 Mon Sep 17 00:00:00 2001 From: Guido Zuidhof Date: Tue, 17 Feb 2026 14:30:18 +0100 Subject: [PATCH 7/7] Bump version to 0.2.0 --- src/client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.php b/src/client.php index b74db52..f8ae7bd 100644 --- a/src/client.php +++ b/src/client.php @@ -6,7 +6,7 @@ use FriendlyCaptcha\SDK\{ClientConfig, VerifyResult, ErrorCodes}; -const VERSION = "0.1.2"; +const VERSION = "0.2.0"; const EU_API_ENDPOINT = "https://eu.frcapi.com"; const GLOBAL_API_ENDPOINT = "https://global.frcapi.com"; const SITEVERIFY_PATH = "/api/v2/captcha/siteverify";