Skip to content

Commit 939fe3f

Browse files
committed
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).
1 parent 1ca150d commit 939fe3f

6 files changed

Lines changed: 128 additions & 112 deletions

File tree

src/Emulation/Browser.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,48 @@ public function profile(): Profile
7171
{
7272
return Catalog::resolve($this);
7373
}
74+
75+
public function impersonateTarget(): ?string
76+
{
77+
return match ($this) {
78+
self::Chrome99 => 'chrome99',
79+
self::Chrome100 => 'chrome100',
80+
self::Chrome101 => 'chrome101',
81+
self::Chrome104 => 'chrome104',
82+
self::Chrome107 => 'chrome107',
83+
self::Chrome110 => 'chrome110',
84+
self::Chrome116 => 'chrome116',
85+
self::Chrome119 => 'chrome119',
86+
self::Chrome120 => 'chrome120',
87+
self::Chrome123 => 'chrome123',
88+
self::Chrome124 => 'chrome124',
89+
self::Chrome131 => 'chrome131',
90+
self::Chrome133a => 'chrome133a',
91+
self::Chrome136 => 'chrome136',
92+
self::Chrome142 => 'chrome142',
93+
self::Chrome145 => 'chrome145',
94+
self::Chrome146 => 'chrome146',
95+
self::Chrome99Android => 'chrome99_android',
96+
self::Chrome131Android => 'chrome131_android',
97+
self::Firefox133 => 'firefox133',
98+
self::Firefox135 => 'firefox135',
99+
self::Firefox144 => 'firefox144',
100+
self::Firefox147 => 'firefox147',
101+
self::Safari153 => 'safari153',
102+
self::Safari155 => 'safari155',
103+
self::Safari170 => 'safari170',
104+
self::Safari172iOS => 'safari172_ios',
105+
self::Safari18 => 'safari180',
106+
self::SafariIPad18 => 'safari180_ios',
107+
self::SafariIOS18 => 'safari180_ios',
108+
self::Safari184 => 'safari184',
109+
self::Safari184iOS => 'safari184_ios',
110+
self::Safari260 => 'safari260',
111+
self::Safari260iOS => 'safari260_ios',
112+
self::Edge99 => 'edge99',
113+
self::Edge101 => 'edge101',
114+
self::Tor145 => 'tor145',
115+
default => null,
116+
};
117+
}
74118
}

src/Emulation/Catalog.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ public static function resolve(Browser $browser): Profile
8585
defaultHeaders: $profile->defaultHeaders,
8686
originalHeaderMap: $profile->originalHeaderMap,
8787
connectionGroup: ConnectionGroup::named($browser->value),
88+
impersonateTarget: $browser->impersonateTarget(),
8889
);
8990
}
9091
}

src/Emulation/Catalog/Safari.php

Lines changed: 52 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@
2020

2121
final class Safari
2222
{
23-
// wreq cipher list (23 ciphers — with CBC SHA384/SHA256 but NO 3DES)
24-
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';
25-
2623
// curl_impersonate Safari 15.3 cipher list (26 ciphers — with SHA384/SHA256 AND 3DES)
2724
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';
2825

@@ -34,34 +31,49 @@ final class Safari
3431

3532
private const string CURVES_LIST = 'X25519:P-256:P-384:P-521';
3633

37-
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';
38-
3934
// curl_impersonate sigalgs for Safari 15.x-17.x (includes ecdsa_sha1 + duplicate rsa_pss_rsae_sha384)
4035
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';
4136

4237
// curl_impersonate sigalgs for Safari 18.x+ (no ecdsa_sha1, has duplicate rsa_pss_rsae_sha384)
4338
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';
4439

45-
// ─── wreq profiles (v18, iPad18, iOS18) — unchanged ───
46-
40+
// Safari 18.0 — uses CI profile to match curl_safari180 binary
4741
public static function v18(): Profile
4842
{
49-
return self::buildProfile(
50-
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',
43+
return new Profile(
44+
tlsOptions: self::ciTls18x(),
45+
http2Options: self::ciHttp2_180(),
46+
defaultHeaders: self::ciHeaders18x(
47+
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',
48+
encoding: 'gzip, deflate, br',
49+
),
50+
originalHeaderMap: self::ciHeaderOrder18x(),
5151
);
5252
}
5353

5454
public static function iPad18(): Profile
5555
{
56-
return self::buildProfile(
57-
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',
56+
return new Profile(
57+
tlsOptions: self::ciTls18x(),
58+
http2Options: self::ciHttp2_180(),
59+
defaultHeaders: self::ciHeaders18x(
60+
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',
61+
encoding: 'gzip, deflate, br',
62+
),
63+
originalHeaderMap: self::ciHeaderOrder18x(),
5864
);
5965
}
6066

6167
public static function iOS18(): Profile
6268
{
63-
return self::buildProfile(
64-
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',
69+
return new Profile(
70+
tlsOptions: self::ciTls18x(),
71+
http2Options: self::ciHttp2_180(),
72+
defaultHeaders: self::ciHeaders18x(
73+
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',
74+
encoding: 'gzip, deflate, br',
75+
),
76+
originalHeaderMap: self::ciHeaderOrder18x(),
6577
);
6678
}
6779

@@ -173,88 +185,6 @@ public static function v260iOS(): Profile
173185
);
174186
}
175187

176-
// ─── wreq private helpers (unchanged) ───
177-
178-
private static function baseTlsOptions(): TlsOptions
179-
{
180-
return TlsOptions::builder()
181-
->minTlsVersion(TlsVersion::TLS_1_2)
182-
->maxTlsVersion(TlsVersion::TLS_1_3)
183-
->cipherList(self::CIPHER_LIST)
184-
->sigalgsList(self::SIGALGS_LIST)
185-
->curvesList(self::CURVES_LIST)
186-
->alpnProtocols([AlpnProtocol::Http2, AlpnProtocol::Http1])
187-
->keyShares([KeyShare::X25519])
188-
->enableOcspStapling(true)
189-
->enableSignedCertTimestamps(false)
190-
->greaseEnabled(false)
191-
->permuteExtensions(false)
192-
->enableEchGrease(false)
193-
->sessionTicket(true)
194-
->build();
195-
}
196-
197-
private static function baseHttp2Options(): Http2Options
198-
{
199-
return Http2Options::builder()
200-
->headerTableSize(4096)
201-
->enablePush(false)
202-
->maxConcurrentStreams(100)
203-
->initialWindowSize(2097152)
204-
->maxFrameSize(16384)
205-
->initialConnWindowSize(10485760)
206-
->headersPseudoOrder(new PseudoHeaderOrder([
207-
PseudoHeader::Method,
208-
PseudoHeader::Scheme,
209-
PseudoHeader::Path,
210-
PseudoHeader::Authority,
211-
]))
212-
->settingsOrder(new SettingsOrder([
213-
SettingId::HeaderTableSize,
214-
SettingId::EnablePush,
215-
SettingId::MaxConcurrentStreams,
216-
SettingId::InitialWindowSize,
217-
SettingId::MaxFrameSize,
218-
]))
219-
->build();
220-
}
221-
222-
/**
223-
* @return array<string, string>
224-
*/
225-
private static function baseHeaders(string $userAgent): array
226-
{
227-
return [
228-
'User-Agent' => $userAgent,
229-
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
230-
'Accept-Language' => 'en-US,en;q=0.9',
231-
'Accept-Encoding' => 'gzip, deflate, br',
232-
];
233-
}
234-
235-
private static function baseHeaderOrder(): OriginalHeaderMap
236-
{
237-
return new OriginalHeaderMap([
238-
'Host',
239-
'Accept',
240-
'User-Agent',
241-
'Accept-Language',
242-
'Accept-Encoding',
243-
'Connection',
244-
'Cookie',
245-
]);
246-
}
247-
248-
private static function buildProfile(string $userAgent): Profile
249-
{
250-
return new Profile(
251-
tlsOptions: self::baseTlsOptions(),
252-
http2Options: self::baseHttp2Options(),
253-
defaultHeaders: self::baseHeaders($userAgent),
254-
originalHeaderMap: self::baseHeaderOrder(),
255-
);
256-
}
257-
258188
// ─── curl_impersonate private helpers ───
259189

260190
// 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
355285
return $builder->build();
356286
}
357287

288+
// HTTP/2: Safari 18.0 (2:0;3:100;4:2097152;8:1;9:1, pseudo msap, weight 256)
289+
private static function ciHttp2_180(): Http2Options
290+
{
291+
return Http2Options::builder()
292+
->enablePush(false)
293+
->maxConcurrentStreams(100)
294+
->initialWindowSize(2097152)
295+
->enableConnectProtocol(true)
296+
->noRfc7540Priorities(true)
297+
->initialConnWindowSize(10420225)
298+
->headersStreamDependency(new StreamDependency(0, 256, false))
299+
->headersPseudoOrder(new PseudoHeaderOrder([
300+
PseudoHeader::Method,
301+
PseudoHeader::Scheme,
302+
PseudoHeader::Authority,
303+
PseudoHeader::Path,
304+
]))
305+
->settingsOrder(new SettingsOrder([
306+
SettingId::EnablePush,
307+
SettingId::MaxConcurrentStreams,
308+
SettingId::InitialWindowSize,
309+
SettingId::EnableConnectProtocol,
310+
SettingId::NoRfc7540Priorities,
311+
]))
312+
->build();
313+
}
314+
358315
// HTTP/2: Safari 18.4 (2:0;3:100;4:2097152;9:1, pseudo msap, weight 256)
359316
private static function ciHttp2_184(): Http2Options
360317
{

src/Emulation/Profile.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function __construct(
2222
public array $defaultHeaders = [],
2323
public ?OriginalHeaderMap $originalHeaderMap = null,
2424
public ?ConnectionGroup $connectionGroup = null,
25+
public ?string $impersonateTarget = null,
2526
) {}
2627

2728
public function tlsOptions(): ?TlsOptions

src/Transport/ProcessTransport.php

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public function __construct(
2121

2222
public function send(RequestInterface $request, Profile $profile, TransportOptions $options): ResponseInterface
2323
{
24-
$binary = $this->binaryPath ?? self::detectBinaryPath();
24+
$binary = $this->resolveBinary($profile);
2525

2626
if ($binary === null) {
2727
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
3030
$args = $this->buildArguments($request, $profile, $options);
3131
$command = escapeshellcmd($binary).' '.implode(' ', array_map(escapeshellarg(...), $args));
3232

33-
// Include response headers with -D - and suppress progress meter
34-
$command .= ' -s -D -';
33+
// -s: suppress progress meter, -S: show errors in stderr, -D -: dump headers to stdout
34+
$command .= ' -sS -D -';
3535

3636
$process = proc_open(
3737
$command,
@@ -78,6 +78,30 @@ public function supportsHttp2Configuration(): bool
7878
return true;
7979
}
8080

81+
private function resolveBinary(Profile $profile): ?string
82+
{
83+
if ($this->binaryPath !== null) {
84+
return $this->binaryPath;
85+
}
86+
87+
if ($profile->impersonateTarget !== null) {
88+
$targetBinary = 'curl_'.$profile->impersonateTarget;
89+
90+
$envPath = getenv('REQXIDE_CURL_IMPERSONATE_DIR');
91+
if ($envPath !== false && is_executable($envPath.'/'.$targetBinary)) {
92+
return $envPath.'/'.$targetBinary;
93+
}
94+
95+
foreach (['/usr/local/bin', '/usr/bin'] as $dir) {
96+
if (is_executable($dir.'/'.$targetBinary)) {
97+
return $dir.'/'.$targetBinary;
98+
}
99+
}
100+
}
101+
102+
return self::detectBinaryPath();
103+
}
104+
81105
public static function detectBinaryPath(): ?string
82106
{
83107
$envPath = getenv('REQXIDE_CURL_IMPERSONATE_PATH');

tests/Unit/Emulation/Catalog/SafariTest.php

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,10 @@
2525
expect($profile->tlsOptions?->enableEchGrease)->toBeFalse();
2626
});
2727

28-
it('does NOT have GREASE enabled for wreq profiles', function (): void {
28+
it('has GREASE enabled for v18', function (): void {
2929
$profile = Safari::v18();
3030

31-
expect($profile->tlsOptions?->greaseEnabled)->toBeFalse();
32-
});
33-
34-
it('has pseudo order of Method, Scheme, Path, Authority for wreq', function (): void {
35-
$profile = Safari::v18();
36-
37-
expect($profile->http2Options?->headersPseudoOrder?->headers)->toBe([
38-
PseudoHeader::Method,
39-
PseudoHeader::Scheme,
40-
PseudoHeader::Path,
41-
PseudoHeader::Authority,
42-
]);
31+
expect($profile->tlsOptions?->greaseEnabled)->toBeTrue();
4332
});
4433

4534
it('has iPad in User-Agent for iPad18', function (): void {
@@ -116,7 +105,7 @@
116105
PseudoHeader::Authority,
117106
PseudoHeader::Path,
118107
]);
119-
})->with([['v184'], ['v184iOS'], ['v260'], ['v260iOS']]);
108+
})->with([['v18'], ['iPad18'], ['iOS18'], ['v184'], ['v184iOS'], ['v260'], ['v260iOS']]);
120109

121110
it('26.x uses TLS 1.2 minimum', function (string $method): void {
122111
/** @var Profile $profile */

0 commit comments

Comments
 (0)