Curl-Impersonate
view release on metacpan or search on metacpan
NAME
Curl::Impersonate - HTTP client that impersonates a browser's TLS/HTTP2
fingerprint
SYNOPSIS
use Curl::Impersonate;
# synchronous
my $c = Curl::Impersonate->new(impersonate => 'chrome131', timeout => 20);
my $res = $c->get('https://example.com/');
# $res = { status => 200, headers => { 'content-type' => '...' }, body => '...' }
my $post = $c->request(
method => 'POST',
url => 'https://example.com/api',
headers => { 'content-type' => 'application/json' },
body => '{"hello":"world"}',
);
# which browsers can I be?
my @targets = Curl::Impersonate->targets;
# asynchronous (concurrent upstreams)
my $m = Curl::Impersonate->multi;
for my $url (@urls) {
my $h = Curl::Impersonate->new(impersonate => 'chrome131');
lib/Curl/Impersonate.pm view on Meta::CPAN
Curl::Impersonate - HTTP client that impersonates a browser's TLS/HTTP2 fingerprint
=head1 SYNOPSIS
use Curl::Impersonate;
# synchronous
my $c = Curl::Impersonate->new(impersonate => 'chrome131', timeout => 20);
my $res = $c->get('https://example.com/');
# $res = { status => 200, headers => { 'content-type' => '...' }, body => '...' }
my $post = $c->request(
method => 'POST',
url => 'https://example.com/api',
headers => { 'content-type' => 'application/json' },
body => '{"hello":"world"}',
);
# which browsers can I be?
my @targets = Curl::Impersonate->targets;
# asynchronous (concurrent upstreams)
my $m = Curl::Impersonate->multi;
for my $url (@urls) {
my $h = Curl::Impersonate->new(impersonate => 'chrome131');
t/60-edge.t view on Meta::CPAN
}
# multiple Set-Cookie preserved as an arrayref -- finding C
{
my $c = Curl::Impersonate->new(impersonate => 'chrome131', timeout => 10);
my $r = $c->get("http://127.0.0.1:$port/cookies");
my $sc = $r->{headers}{'set-cookie'};
is(ref $sc, 'ARRAY', 'repeated Set-Cookie kept as an arrayref');
is_deeply($sc, ['a=1', 'b=2', 'c=3'], 'all Set-Cookie values preserved')
if ref $sc eq 'ARRAY';
is($r->{headers}{'content-type'}, 'text/plain', 'a single-valued header stays a scalar');
}
# A CR or LF in a caller-supplied header value would split the request: libcurl
# passes the line through verbatim, so the origin sees an extra header. These
# croak before anything reaches the wire.
{
my $h = Curl::Impersonate->new(impersonate => 'chrome131', timeout => 3);
for my $case (['CRLF in a value', "good\r\nX-Injected: yes"],
['LF in a value', "a\nb"],
['NUL in a value', "a\0b"]) {
t/61-http-method.t view on Meta::CPAN
exit 0;
}
close $srv;
my $c = Curl::Impersonate->new(impersonate => 'chrome131', timeout => 10);
my $get = $c->get("http://127.0.0.1:$port/");
my $greq = $get->{body} // ''; # the whole body IS the echoed request
# a bodyless GET must not send Content-Type/Content-Length (was: POSTFIELDS=NULL
# left method=POST, so curl emitted them -- a "no browser does this" tell)
unlike($greq, qr/content-type:/i, 'bodyless GET sends no Content-Type');
unlike($greq, qr/content-length:/i, 'bodyless GET sends no Content-Length');
like($greq, qr{^GET /}, 'request is a GET');
# no h2c upgrade headers on cleartext (real Chrome only speaks h2 over TLS/ALPN)
unlike($greq, qr/upgrade:\s*h2c/i, 'cleartext request carries no Upgrade: h2c');
unlike($greq, qr/http2-settings:/i, 'cleartext request carries no HTTP2-Settings');
# a POST with a body still carries them
my $post = $c->request(method => 'POST', url => "http://127.0.0.1:$port/", body => 'x=1');
my $preq = $post->{body} // '';
t/63-redirect.t view on Meta::CPAN
}
exit 0;
}
close $srv;
my $c = Curl::Impersonate->new(impersonate => 'chrome131', timeout => 10, follow_redirects => 1);
my $r = $c->get("http://127.0.0.1:$port/start");
is($r->{status}, 200, 'followed the 302 to the final 200');
# the result must carry ONLY the final response's headers, not the 302 hop merged in
is($r->{headers}{'content-type'}, 'application/json',
'content-type is the final scalar (not an arrayref merged with the 302 text/html)');
ok(!ref $r->{headers}{'content-type'}, 'content-type is a scalar, not an arrayref');
ok(!exists $r->{headers}{location}, 'the 302-only Location did not leak into the final headers');
ok(!exists $r->{headers}{'set-cookie'}, 'the 302-only Set-Cookie did not leak into the final headers');
kill 'TERM', $pid; waitpid($pid, 0);
done_testing;
xt/20-fingerprint.t view on Meta::CPAN
my $c = Curl::Impersonate->new(impersonate => 'chrome131', timeout => 20);
my $r = $c->get('https://tls.peet.ws/api/all');
is($r->{status}, 200, 'fingerprint endpoint reachable') or diag($r->{error} // '');
my ($ja4) = ($r->{body} // '') =~ /"ja4":\s*"([^"]+)"/;
ok($ja4, "got a ja4 ($ja4)");
like($ja4, qr/^t13d/, 'JA4 is a TLS 1.3 client (Chrome-shaped)');
like($r->{body}, qr/"akamai_fingerprint":\s*"[^"]/, 'HTTP/2 (Akamai) fingerprint present');
like($r->{body}, qr/"user_agent":\s*"[^"]*Chrome/, 'the Chrome default UA was applied');
# response plumbing
is($r->{headers}{'content-type'}, 'application/json', 'response headers parsed + lowercased');
ok(length($r->{body}) > 100, 'body captured');
done_testing;
( run in 3.715 seconds using v1.01-cache-2.11-cpan-800906f7e73 )