Curl-Impersonate

 view release on metacpan or  search on metacpan

README  view on Meta::CPAN

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 )