NAME

Proxy::Impersonate - EV MITM proxy that re-originates with a browser TLS/HTTP2 fingerprint

SYNOPSIS

use EV;
use Proxy::Impersonate;

my $proxy = Proxy::Impersonate->new(
    impersonate => 'chrome131',
    listen      => '127.0.0.1:0',   # ephemeral port
    cert_dir    => '/path/to/ca',   # persists the self-signed cert
);
printf "proxy on 127.0.0.1:%d\n", $proxy->port;
$proxy->run;   # EV loop

A client (or EV::WebKit) uses it as an HTTP/HTTPS forward proxy. For HTTPS the client issues CONNECT host:443; the proxy terminates that TLS with its own cert, then re-originates the request upstream through Curl::Impersonate so the origin sees the chosen browser's TLS (JA3/JA4) and HTTP/2 (Akamai) fingerprint.

DESCRIPTION

EV::WebKit cannot present a browser's connection fingerprint itself -- WebKitGTK speaks GnuTLS/libsoup. This proxy sits in front of it: it MITMs WebKit's TLS on localhost, reads the plaintext request, and sends it upstream with a real browser's handshake via libcurl-impersonate. The origin's TLS/HTTP2 fingerprint therefore matches the impersonated browser, not WebKit.

It is an HTTP client, not a browser: it reproduces the connection fingerprint only. HTTP/3 and WebSockets are out of scope in this release, as are streaming request uploads. A CONNECT client must wait for the 200 response before beginning its TLS handshake: a ClientHello optimistically coalesced into the CONNECT segment is not supported (the connection is closed rather than left to stall). Browsers and libsoup -- the intended clients -- already do this.

Header-order ceiling: curl-impersonate reproduces the target's TLS (JA3/JA4) and HTTP/2 (Akamai) fingerprints exactly, and template headers keep their positions. But headers the proxy adds that are not in the template (Cookie, Referer, and the high-entropy Sec-CH-UA hints) are appended after the template block rather than in the browser's exact positions, so a header-order-only hash (e.g. JA4H) will not match on requests carrying them. The dominant fingerprints (JA3/JA4/Akamai) are unaffected.

Priority ceiling: the proxy synthesizes a per-destination Accept, but the HTTP/2 request priority (the priority header and any PRIORITY_UPDATE frames) comes from curl-impersonate's static template, not from the resource type. Chrome varies urgency per resource; the proxy does not, so a resource-priority-aware fingerprinter could tell subresources apart. This lives in curl-impersonate's protocol layer, not in a header the proxy re-writes.

TRUST MODEL

WebKitGTK 6.0 exposes no way to trust a custom CA (its network process honors neither SSL_CERT_FILE nor a settable GTlsDatabase; this was verified by a spike). So the proxy presents a single self-signed cert and the WebKit side is told to accept it:

# on the EV::WebKit network session (sub-project 3 wires this):
$session->set_tls_errors_policy('ignore');
$browser->set_proxy("http://127.0.0.1:" . $proxy->port);

This is safe: the WebKit-to-proxy hop is localhost, and the proxy re-verifies the real origin certificate upstream (verify => 1, the default).

METHODS

new

my $proxy = Proxy::Impersonate->new(%opt);
impersonate => $target

Required. The Curl::Impersonate target (e.g. 'chrome131') applied to every upstream request. Keep it coherent with EV::WebKit's fingerprint profile.

listen => 'host:port'

Bind address; default '127.0.0.1:0' (an ephemeral port, reported by "port").

cert_dir => $path

Where the self-signed cert is persisted. Defaults to a temporary directory (mode 0700), which is the safe case.

If you point this at a location of your own, note that an existing key there is adopted, and whoever can write that key can impersonate this proxy to the client it fronts -- which is configured to accept its certificate. So a key that is group- or world-accessible, owned by another user, or a symlink is refused rather than used. Keep it 0600 and yours.

on_request => sub { my ($req) = @_; ... }

Per-request interception hook, called after TLS termination and before anything goes upstream -- so it sees every request the client makes (navigations, subresources, XHR, fetch), and can rewrite, answer or refuse each one.

$req is a hashref with method, url, headers (a lowercase-keyed hashref), body and host (the bare hostname). Modify any of them in place to rewrite the request:

on_request => sub {
    my ($req) = @_;
    $req->{url} =~ s{^https://cdn\.}{https://local-mirror.};
    $req->{headers}{'x-trace'} = 'yes';
    return;                       # proceed with the rewrite
}

Return value decides what happens next:

nothing (or undef)

The request proceeds, carrying whatever rewrites the handler made.

a hashref

Answered locally; the network is never touched. Keys: status (default 200), headers, body. Content-Length is computed from the body, not taken from the handler, so a handler that disagrees with its own body cannot desynchronise the connection. Useful for mocking an endpoint, or for blocking with a visible answer:

return { status => 403, body => 'blocked' } if $req->{host} =~ /ads\./;
the string 'abort'

The connection is closed without any response -- the closest thing to a network-level block.

What the handler sees in headers is the set this proxy forces on top of Curl::Impersonate's template: what the client sent that must be carried through (Cookie, Referer, Sec-Fetch-*, Content-Type, ...). It does not include the headers curl-impersonate supplies from its fingerprint template (User-Agent, Accept, Accept-Language, Sec-CH-UA, ...) -- forwarding the client's own would break the very fingerprint this proxy exists to reproduce. Setting any of those keys still works and overrides the template; you simply cannot read their template values here.

A handler that dies refuses the request with a 502 and warns. It fails closed deliberately: this hook is used to block traffic, so an exception must not quietly let through exactly what the caller was trying to stop.

on_response => sub { my ($res) = @_; ... }

The counterpart to on_request, called when the upstream response head arrives -- before any of it reaches the client, so the status and headers can be observed or rewritten. Stripping a policy header is the usual reason:

on_response => sub {
    my ($res) = @_;
    delete $res->{headers}{'content-security-policy'};
    delete $res->{headers}{'x-frame-options'};
    return;
}

$res has status, headers (lowercase-keyed), and -- for context -- the request's url, method and host. Modify status or headers in place; the return value is ignored.

The framing is not yours. Content-Length, Connection and the hop-by-hop headers are snapshotted before the hook and forced back after it: a handler that edits them does not desynchronise its own connection, it desynchronises the client's. Setting content-length to a value that disagrees with the body, or reintroducing transfer-encoding, therefore has no effect.

Bodies are out of scope: they stream through with backpressure, and buffering them to offer a rewrite would defeat that. Use on_request's synthetic response if you need to replace content wholesale.

A handler that dies passes the response through unchanged and warns. It fails open, unlike on_request: the request has already been made and the response already fetched, so there is no security decision left to protect, and breaking the page over a bug in an observer would be the worse outcome.

timeout => $seconds

Per-request upstream timeout. Default 30.

verify => $bool

Verify the real origin's certificate upstream. Default true; leave it on.

follow_redirects => $bool

Whether the upstream client follows redirects. Default false -- the browser handles 3xx itself, so the proxy forwards them.

port

The bound listen port (useful with listen => '...:0').

cert_dir

The directory holding the self-signed cert.

run

Run the EV loop. Blocks until "stop" or EV::break.

stop

Stop accepting and break the EV loop. Use this when the proxy owns the loop -- i.e. when you called "run".

shutdown

Stop accepting, close every active connection, and release the curl_multi wiring -- without breaking the EV loop, so a caller whose loop is shared keeps running. That is the difference from "stop": this is the in-process teardown, and it is what EV::WebKit calls when the browser it fronts quits.

Safe to call more than once, and safe from inside a callback: it is plain EV/Perl with no GObject-Introspection dispatch to unwind.

REQUEST HANDLING LIMITS

The proxy refuses what it cannot frame exactly, because guessing would leave bytes in the read buffer to be re-parsed as a second request:

  • A request head over 64KB is closed, which also caps a slow-drip head.

  • A chunked request body gets 411: there is no chunked decoder, since streaming uploads are out of scope.

  • A Content-Length that is repeated with disagreeing values, or is not a plain non-negative integer, gets 400. An identical repeat is legal and is accepted.

  • A header value containing NUL, CR or LF gets 400. RFC 9110 bars all three from a field value; CR and LF cannot survive the line split, but a NUL can, and forwarded headers are re-sent upstream.

Request bodies are buffered whole before being forwarded -- there is no streaming upload path, and therefore no size cap. A client that uploads a gigabyte makes the proxy hold a gigabyte. That is tolerable because the listener is bound to localhost by default and fronts one browser, but it is worth knowing before binding it anywhere else.

REQUIREMENTS

Curl::Impersonate 0.01 or later, Net::SSLeay, EV.

SEE ALSO

Curl::Impersonate, EV::WebKit

AUTHOR

vividsnow

LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.