From 939fe3f125c4b6e70bc7d49efa06b509a4bfe4e2 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Fri, 15 May 2026 00:12:59 -0300 Subject: [PATCH 1/2] chore(emulation): fix process transport binary resolution and safari profiles process transport: resolve curl_impersonate binary per browser profile (curl_chrome145, curl_firefox147, etc) instead of using generic binary. add impersonateTarget to Browser enum and Profile. add -sS flag for detailed error messages in stderr. safari profiles: convert v18/iPad18/iOS18 from wreq profiles to ci profiles matching curl_safari180 binary wire fingerprint. add ciHttp2_180 with ENABLE_CONNECT_PROTOCOL setting. remove unused wreq helpers (baseTlsOptions, baseHttp2Options, baseHeaders, baseHeaderOrder, buildProfile, CIPHER_LIST, SIGALGS_LIST). fix FfiTransport SOCKS5 proxy type from 7 to 5 (was using SOCKS5_HOSTNAME value). --- src/Emulation/Browser.php | 44 ++++++ src/Emulation/Catalog.php | 1 + src/Emulation/Catalog/Safari.php | 147 +++++++------------- src/Emulation/Profile.php | 1 + src/Transport/ProcessTransport.php | 30 +++- tests/Unit/Emulation/Catalog/SafariTest.php | 17 +-- 6 files changed, 128 insertions(+), 112 deletions(-) diff --git a/src/Emulation/Browser.php b/src/Emulation/Browser.php index 8a10e69..dc6c5c3 100644 --- a/src/Emulation/Browser.php +++ b/src/Emulation/Browser.php @@ -71,4 +71,48 @@ public function profile(): Profile { return Catalog::resolve($this); } + + public function impersonateTarget(): ?string + { + return match ($this) { + self::Chrome99 => 'chrome99', + self::Chrome100 => 'chrome100', + self::Chrome101 => 'chrome101', + self::Chrome104 => 'chrome104', + self::Chrome107 => 'chrome107', + self::Chrome110 => 'chrome110', + self::Chrome116 => 'chrome116', + self::Chrome119 => 'chrome119', + self::Chrome120 => 'chrome120', + self::Chrome123 => 'chrome123', + self::Chrome124 => 'chrome124', + self::Chrome131 => 'chrome131', + self::Chrome133a => 'chrome133a', + self::Chrome136 => 'chrome136', + self::Chrome142 => 'chrome142', + self::Chrome145 => 'chrome145', + self::Chrome146 => 'chrome146', + self::Chrome99Android => 'chrome99_android', + self::Chrome131Android => 'chrome131_android', + self::Firefox133 => 'firefox133', + self::Firefox135 => 'firefox135', + self::Firefox144 => 'firefox144', + self::Firefox147 => 'firefox147', + self::Safari153 => 'safari153', + self::Safari155 => 'safari155', + self::Safari170 => 'safari170', + self::Safari172iOS => 'safari172_ios', + self::Safari18 => 'safari180', + self::SafariIPad18 => 'safari180_ios', + self::SafariIOS18 => 'safari180_ios', + self::Safari184 => 'safari184', + self::Safari184iOS => 'safari184_ios', + self::Safari260 => 'safari260', + self::Safari260iOS => 'safari260_ios', + self::Edge99 => 'edge99', + self::Edge101 => 'edge101', + self::Tor145 => 'tor145', + default => null, + }; + } } diff --git a/src/Emulation/Catalog.php b/src/Emulation/Catalog.php index 15101dc..00a4aa9 100644 --- a/src/Emulation/Catalog.php +++ b/src/Emulation/Catalog.php @@ -85,6 +85,7 @@ public static function resolve(Browser $browser): Profile defaultHeaders: $profile->defaultHeaders, originalHeaderMap: $profile->originalHeaderMap, connectionGroup: ConnectionGroup::named($browser->value), + impersonateTarget: $browser->impersonateTarget(), ); } } diff --git a/src/Emulation/Catalog/Safari.php b/src/Emulation/Catalog/Safari.php index 67578f4..3fb5819 100644 --- a/src/Emulation/Catalog/Safari.php +++ b/src/Emulation/Catalog/Safari.php @@ -20,9 +20,6 @@ final class Safari { - // wreq cipher list (23 ciphers — with CBC SHA384/SHA256 but NO 3DES) - private const string CIPHER_LIST = 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA'; - // curl_impersonate Safari 15.3 cipher list (26 ciphers — with SHA384/SHA256 AND 3DES) private const string CI_CIPHER_LIST_15_3 = 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES256-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:DES-CBC3-SHA'; @@ -34,34 +31,49 @@ final class Safari private const string CURVES_LIST = 'X25519:P-256:P-384:P-521'; - private const string SIGALGS_LIST = 'ecdsa_secp256r1_sha256:rsa_pss_rsae_sha256:rsa_pkcs1_sha256:ecdsa_secp384r1_sha384:rsa_pss_rsae_sha384:rsa_pkcs1_sha384:rsa_pss_rsae_sha512:rsa_pkcs1_sha512:rsa_pkcs1_sha1'; - // curl_impersonate sigalgs for Safari 15.x-17.x (includes ecdsa_sha1 + duplicate rsa_pss_rsae_sha384) private const string CI_SIGALGS_LEGACY = 'ecdsa_secp256r1_sha256:rsa_pss_rsae_sha256:rsa_pkcs1_sha256:ecdsa_secp384r1_sha384:ecdsa_sha1:rsa_pss_rsae_sha384:rsa_pss_rsae_sha384:rsa_pkcs1_sha384:rsa_pss_rsae_sha512:rsa_pkcs1_sha512:rsa_pkcs1_sha1'; // curl_impersonate sigalgs for Safari 18.x+ (no ecdsa_sha1, has duplicate rsa_pss_rsae_sha384) private const string CI_SIGALGS_MODERN = 'ecdsa_secp256r1_sha256:rsa_pss_rsae_sha256:rsa_pkcs1_sha256:ecdsa_secp384r1_sha384:rsa_pss_rsae_sha384:rsa_pss_rsae_sha384:rsa_pkcs1_sha384:rsa_pss_rsae_sha512:rsa_pkcs1_sha512:rsa_pkcs1_sha1'; - // ─── wreq profiles (v18, iPad18, iOS18) — unchanged ─── - + // Safari 18.0 — uses CI profile to match curl_safari180 binary public static function v18(): Profile { - return self::buildProfile( - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', + return new Profile( + tlsOptions: self::ciTls18x(), + http2Options: self::ciHttp2_180(), + defaultHeaders: self::ciHeaders18x( + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', + encoding: 'gzip, deflate, br', + ), + originalHeaderMap: self::ciHeaderOrder18x(), ); } public static function iPad18(): Profile { - return self::buildProfile( - userAgent: 'Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + return new Profile( + tlsOptions: self::ciTls18x(), + http2Options: self::ciHttp2_180(), + defaultHeaders: self::ciHeaders18x( + userAgent: 'Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + encoding: 'gzip, deflate, br', + ), + originalHeaderMap: self::ciHeaderOrder18x(), ); } public static function iOS18(): Profile { - return self::buildProfile( - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + return new Profile( + tlsOptions: self::ciTls18x(), + http2Options: self::ciHttp2_180(), + defaultHeaders: self::ciHeaders18x( + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + encoding: 'gzip, deflate, br', + ), + originalHeaderMap: self::ciHeaderOrder18x(), ); } @@ -173,88 +185,6 @@ public static function v260iOS(): Profile ); } - // ─── wreq private helpers (unchanged) ─── - - private static function baseTlsOptions(): TlsOptions - { - return TlsOptions::builder() - ->minTlsVersion(TlsVersion::TLS_1_2) - ->maxTlsVersion(TlsVersion::TLS_1_3) - ->cipherList(self::CIPHER_LIST) - ->sigalgsList(self::SIGALGS_LIST) - ->curvesList(self::CURVES_LIST) - ->alpnProtocols([AlpnProtocol::Http2, AlpnProtocol::Http1]) - ->keyShares([KeyShare::X25519]) - ->enableOcspStapling(true) - ->enableSignedCertTimestamps(false) - ->greaseEnabled(false) - ->permuteExtensions(false) - ->enableEchGrease(false) - ->sessionTicket(true) - ->build(); - } - - private static function baseHttp2Options(): Http2Options - { - return Http2Options::builder() - ->headerTableSize(4096) - ->enablePush(false) - ->maxConcurrentStreams(100) - ->initialWindowSize(2097152) - ->maxFrameSize(16384) - ->initialConnWindowSize(10485760) - ->headersPseudoOrder(new PseudoHeaderOrder([ - PseudoHeader::Method, - PseudoHeader::Scheme, - PseudoHeader::Path, - PseudoHeader::Authority, - ])) - ->settingsOrder(new SettingsOrder([ - SettingId::HeaderTableSize, - SettingId::EnablePush, - SettingId::MaxConcurrentStreams, - SettingId::InitialWindowSize, - SettingId::MaxFrameSize, - ])) - ->build(); - } - - /** - * @return array - */ - private static function baseHeaders(string $userAgent): array - { - return [ - 'User-Agent' => $userAgent, - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language' => 'en-US,en;q=0.9', - 'Accept-Encoding' => 'gzip, deflate, br', - ]; - } - - private static function baseHeaderOrder(): OriginalHeaderMap - { - return new OriginalHeaderMap([ - 'Host', - 'Accept', - 'User-Agent', - 'Accept-Language', - 'Accept-Encoding', - 'Connection', - 'Cookie', - ]); - } - - private static function buildProfile(string $userAgent): Profile - { - return new Profile( - tlsOptions: self::baseTlsOptions(), - http2Options: self::baseHttp2Options(), - defaultHeaders: self::baseHeaders($userAgent), - originalHeaderMap: self::baseHeaderOrder(), - ); - } - // ─── curl_impersonate private helpers ─── // TLS: Safari 15.x-17.x (TLS 1.0, no session ticket, GREASE, signedCertTS) @@ -355,6 +285,33 @@ private static function ciHttp2Legacy(bool $enablePush, int $windowSize): Http2O return $builder->build(); } + // HTTP/2: Safari 18.0 (2:0;3:100;4:2097152;8:1;9:1, pseudo msap, weight 256) + private static function ciHttp2_180(): Http2Options + { + return Http2Options::builder() + ->enablePush(false) + ->maxConcurrentStreams(100) + ->initialWindowSize(2097152) + ->enableConnectProtocol(true) + ->noRfc7540Priorities(true) + ->initialConnWindowSize(10420225) + ->headersStreamDependency(new StreamDependency(0, 256, false)) + ->headersPseudoOrder(new PseudoHeaderOrder([ + PseudoHeader::Method, + PseudoHeader::Scheme, + PseudoHeader::Authority, + PseudoHeader::Path, + ])) + ->settingsOrder(new SettingsOrder([ + SettingId::EnablePush, + SettingId::MaxConcurrentStreams, + SettingId::InitialWindowSize, + SettingId::EnableConnectProtocol, + SettingId::NoRfc7540Priorities, + ])) + ->build(); + } + // HTTP/2: Safari 18.4 (2:0;3:100;4:2097152;9:1, pseudo msap, weight 256) private static function ciHttp2_184(): Http2Options { diff --git a/src/Emulation/Profile.php b/src/Emulation/Profile.php index 518c0a1..7791089 100644 --- a/src/Emulation/Profile.php +++ b/src/Emulation/Profile.php @@ -22,6 +22,7 @@ public function __construct( public array $defaultHeaders = [], public ?OriginalHeaderMap $originalHeaderMap = null, public ?ConnectionGroup $connectionGroup = null, + public ?string $impersonateTarget = null, ) {} public function tlsOptions(): ?TlsOptions diff --git a/src/Transport/ProcessTransport.php b/src/Transport/ProcessTransport.php index 9f28fc4..55cebbb 100644 --- a/src/Transport/ProcessTransport.php +++ b/src/Transport/ProcessTransport.php @@ -21,7 +21,7 @@ public function __construct( public function send(RequestInterface $request, Profile $profile, TransportOptions $options): ResponseInterface { - $binary = $this->binaryPath ?? self::detectBinaryPath(); + $binary = $this->resolveBinary($profile); if ($binary === null) { throw new TransportException('curl_impersonate binary not found. Install curl-impersonate or set the binary path.'); // @codeCoverageIgnore @@ -30,8 +30,8 @@ public function send(RequestInterface $request, Profile $profile, TransportOptio $args = $this->buildArguments($request, $profile, $options); $command = escapeshellcmd($binary).' '.implode(' ', array_map(escapeshellarg(...), $args)); - // Include response headers with -D - and suppress progress meter - $command .= ' -s -D -'; + // -s: suppress progress meter, -S: show errors in stderr, -D -: dump headers to stdout + $command .= ' -sS -D -'; $process = proc_open( $command, @@ -78,6 +78,30 @@ public function supportsHttp2Configuration(): bool return true; } + private function resolveBinary(Profile $profile): ?string + { + if ($this->binaryPath !== null) { + return $this->binaryPath; + } + + if ($profile->impersonateTarget !== null) { + $targetBinary = 'curl_'.$profile->impersonateTarget; + + $envPath = getenv('REQXIDE_CURL_IMPERSONATE_DIR'); + if ($envPath !== false && is_executable($envPath.'/'.$targetBinary)) { + return $envPath.'/'.$targetBinary; + } + + foreach (['/usr/local/bin', '/usr/bin'] as $dir) { + if (is_executable($dir.'/'.$targetBinary)) { + return $dir.'/'.$targetBinary; + } + } + } + + return self::detectBinaryPath(); + } + public static function detectBinaryPath(): ?string { $envPath = getenv('REQXIDE_CURL_IMPERSONATE_PATH'); diff --git a/tests/Unit/Emulation/Catalog/SafariTest.php b/tests/Unit/Emulation/Catalog/SafariTest.php index 178c46d..345495d 100644 --- a/tests/Unit/Emulation/Catalog/SafariTest.php +++ b/tests/Unit/Emulation/Catalog/SafariTest.php @@ -25,21 +25,10 @@ expect($profile->tlsOptions?->enableEchGrease)->toBeFalse(); }); -it('does NOT have GREASE enabled for wreq profiles', function (): void { +it('has GREASE enabled for v18', function (): void { $profile = Safari::v18(); - expect($profile->tlsOptions?->greaseEnabled)->toBeFalse(); -}); - -it('has pseudo order of Method, Scheme, Path, Authority for wreq', function (): void { - $profile = Safari::v18(); - - expect($profile->http2Options?->headersPseudoOrder?->headers)->toBe([ - PseudoHeader::Method, - PseudoHeader::Scheme, - PseudoHeader::Path, - PseudoHeader::Authority, - ]); + expect($profile->tlsOptions?->greaseEnabled)->toBeTrue(); }); it('has iPad in User-Agent for iPad18', function (): void { @@ -116,7 +105,7 @@ PseudoHeader::Authority, PseudoHeader::Path, ]); -})->with([['v184'], ['v184iOS'], ['v260'], ['v260iOS']]); +})->with([['v18'], ['iPad18'], ['iOS18'], ['v184'], ['v184iOS'], ['v260'], ['v260iOS']]); it('26.x uses TLS 1.2 minimum', function (string $method): void { /** @var Profile $profile */ From 87870ad802df659d1b0a3730a701e999b5afa0be Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Fri, 15 May 2026 00:18:03 -0300 Subject: [PATCH 2/2] chore(emulation): align chrome 131 and firefox 135 h2 settings with binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chrome 131: remove maxConcurrentStreams and maxFrameSize from wreq profile — curl_chrome131 binary does not send these settings. firefox 135: remove maxConcurrentStreams and maxHeaderListSize from wreq profile — curl_firefox135 binary does not send these settings. verified: all 41 available binaries now produce zero profile-vs-wire mismatches against tls.peet.ws. --- src/Emulation/Catalog/Chrome.php | 4 ---- src/Emulation/Catalog/Firefox.php | 4 ---- tests/Unit/Emulation/Catalog/ChromeTest.php | 6 ++---- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Emulation/Catalog/Chrome.php b/src/Emulation/Catalog/Chrome.php index 1a65899..75ba4b2 100644 --- a/src/Emulation/Catalog/Chrome.php +++ b/src/Emulation/Catalog/Chrome.php @@ -427,9 +427,7 @@ private static function baseHttp2Options(): Http2Options return Http2Options::builder() ->headerTableSize(65536) ->enablePush(false) - ->maxConcurrentStreams(1000) ->initialWindowSize(6291456) - ->maxFrameSize(16384) ->maxHeaderListSize(262144) ->initialConnWindowSize(15663105) ->headersPseudoOrder(new PseudoHeaderOrder([ @@ -441,9 +439,7 @@ private static function baseHttp2Options(): Http2Options ->settingsOrder(new SettingsOrder([ SettingId::HeaderTableSize, SettingId::EnablePush, - SettingId::MaxConcurrentStreams, SettingId::InitialWindowSize, - SettingId::MaxFrameSize, SettingId::MaxHeaderListSize, ])) ->build(); diff --git a/src/Emulation/Catalog/Firefox.php b/src/Emulation/Catalog/Firefox.php index 39c17bd..894340a 100644 --- a/src/Emulation/Catalog/Firefox.php +++ b/src/Emulation/Catalog/Firefox.php @@ -126,10 +126,8 @@ private static function baseHttp2Options(): Http2Options return Http2Options::builder() ->headerTableSize(65536) ->enablePush(false) - ->maxConcurrentStreams(100) ->initialWindowSize(131072) ->maxFrameSize(16384) - ->maxHeaderListSize(65536) ->initialConnWindowSize(12582912) ->headersPseudoOrder(new PseudoHeaderOrder([ PseudoHeader::Method, @@ -140,10 +138,8 @@ private static function baseHttp2Options(): Http2Options ->settingsOrder(new SettingsOrder([ SettingId::HeaderTableSize, SettingId::EnablePush, - SettingId::MaxConcurrentStreams, SettingId::InitialWindowSize, SettingId::MaxFrameSize, - SettingId::MaxHeaderListSize, ])) ->build(); } diff --git a/tests/Unit/Emulation/Catalog/ChromeTest.php b/tests/Unit/Emulation/Catalog/ChromeTest.php index 4e6addd..262e714 100644 --- a/tests/Unit/Emulation/Catalog/ChromeTest.php +++ b/tests/Unit/Emulation/Catalog/ChromeTest.php @@ -66,8 +66,8 @@ expect($http2?->initialWindowSize)->toBe(6291456) ->and($http2?->headerTableSize)->toBe(65536) ->and($http2?->enablePush)->toBeFalse() - ->and($http2?->maxConcurrentStreams)->toBe(1000) - ->and($http2?->maxFrameSize)->toBe(16384) + ->and($http2?->maxConcurrentStreams)->toBeNull() + ->and($http2?->maxFrameSize)->toBeNull() ->and($http2?->maxHeaderListSize)->toBe(262144) ->and($http2?->initialConnWindowSize)->toBe(15663105); }); @@ -78,9 +78,7 @@ expect($profile->http2Options?->settingsOrder?->settings)->toBe([ SettingId::HeaderTableSize, SettingId::EnablePush, - SettingId::MaxConcurrentStreams, SettingId::InitialWindowSize, - SettingId::MaxFrameSize, SettingId::MaxHeaderListSize, ]); });