Show HN: A local MitM proxy to control TLS fingerprints

Aug 18, 2026 08:07 PM - 2 hours ago 1

 MIT

A section MITM proxy that lets you power TLS fingerprints (JA3/JA4), HTTP/2 fingerprints, HTTP header order, User-Agent, and root IP headers — each from a azygous YAML config file.

A Chrome extension is included for toggling the proxy and switching fingerprint profiles straight from the browser toolbar without restarting the proxy.

Intended for authorized information testing of WAF bot-detection systems. Route curl, browsers, aliases Playwright done the proxy to observe really different fingerprint combinations are classified.

curl / browser / Playwright │ HTTP CONNECT (to proxy) ▼ ┌─────────────────────────────────────────┐ │ impersonate-proxy │ │ │ │ MITM TLS ◄──────────────► uTLS │ │ (our CA cert) (custom JA3/4) │ │ │ │ Header rewriter (UA, order, add/del) │ │ HTTP/2 framer (SETTINGS, WINDOW_UPDATE│ │ pseudo-header order) │ └─────────────────────────────────────────┘ │ Custom TLS ClientHello + HTTP/2 ▼ Target server / WAF
Layer What you tin control
TLS Cipher suites, extensions, their bid (JA3 / JA4) via uTLS presets aliases a afloat civilization custom_hello spec
HTTP/1.1 Header order, User-Agent, add/remove immoderate header, IP spoofing (X-Forwarded-For / True-Client-IP)
HTTP/2 SETTINGS values & order, WINDOW_UPDATE, pseudo-header bid (HTTP/2 fingerprint)
  • macOS aliases Linux (amd64 / arm64)
  • Go 1.22+

The distro-packaged Go is often outdated. Install the charismatic binary directly:

# Download and extract (replace 1.22.5 pinch the latest from https://go.dev/dl/) curl -OL https://go.dev/dl/go1.22.5.linux-amd64.tar.gz sudo rm -rf /usr/local/go sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz # Add to PATH (add this statement to ~/.bashrc aliases ~/.zshrc to make it permanent) export PATH=$PATH:/usr/local/go/bin

Verify:

go version # spell type go1.22.5 linux/amd64

ARM64 (Raspberry Pi, AWS Graviton, etc.): switch linux-amd64 pinch linux-arm64 successful the download URL.

git clone https://github.com/ytkoka/impersonate-proxy.git cd impersonate-proxy make build

2. Generate the MITM CA certificate

The CA is generated automatically connected first run. Start the proxy erstwhile to create ca.crt and ca.key:

make run # 2026/04/22 12:00:00 generated CA certificate → ca.crt # 2026/04/22 12:00:00 listening connected 127.0.0.1:8080 preset=chrome

Stop it pinch Ctrl-C.

3. Trust the CA certificate

Clients request to spot your MITM CA truthful they don't cull the proxy-generated leafage certificates.

macOS strategy keychain (affects each apps):

make trust-ca # runs: sudo information add-trusted-cert ...

Linux strategy trust (affects each apps; requires ca-certificates package):

# Debian / Ubuntu sudo cp ca.crt /usr/local/share/ca-certificates/impersonate-proxy.crt sudo update-ca-certificates # RHEL / Fedora / Amazon Linux sudo cp ca.crt /etc/pki/ca-trust/source/anchors/impersonate-proxy.crt sudo update-ca-trust

curl only (no system-wide change):

Playwright / Node.js:

export NODE_EXTRA_CA_CERTS="$(pwd)/ca.crt"

Firefox: Preferences → Privacy & Security → View Certificates → Authorities → Import ca.crt

Edit config.yaml earlier starting the proxy. All fields person defaults — you only request to specify what you want to override.

listen: "127.0.0.1:8080" mgmt_listen: "127.0.0.1:8081" # guidance API utilized by the Chrome hold (empty to disable) ca_cert: "ca.crt" ca_key: "ca.key" tls: # TLS fingerprint preset (controls JA3 / JA4) # Options: chrome | firefox | safari | separator | ios | random | golang preset: "chrome" http: # Override User-Agent (leave quiet to walk done the client's UA) user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, for illustration Gecko) Chrome/131.0.0.0 Safari/537.36" # Spoof root IP: sets some X-Forwarded-For and True-Client-IP to this value, # replacing immoderate values the customer whitethorn person already group (leave quiet to disable) # client_ip: "1.2.3.4" # Emit headers successful this order; headers not listed are appended after header_order: - "Host" - "User-Agent" - "Accept" - "Accept-Language" - "Accept-Encoding" - "Connection" # Add aliases overwrite headers add_headers: Accept-Language: "ja,en-US;q=0.9,en;q=0.8" # Remove headers earlier forwarding remove_headers: [] http2: enabled: true # SETTINGS framework entries — id and bid some impact the HTTP/2 fingerprint. # RFC 7540 §11.3 IDs: # 1=HEADER_TABLE_SIZE 2=ENABLE_PUSH 3=MAX_CONCURRENT_STREAMS # 4=INITIAL_WINDOW_SIZE 5=MAX_FRAME_SIZE 6=MAX_HEADER_LIST_SIZE settings: - { id: 1, val: 65536 } # Chrome defaults shown here - { id: 2, val: 0 } - { id: 4, val: 6291456 } - { id: 6, val: 262144 } # Connection-level WINDOW_UPDATE increment window_update: 15663105 # Order of pseudo-headers successful the HEADERS frame pseudo_header_order: [method, authority, scheme, path]

When the proxy starts it besides exposes a lightweight HTTP API connected mgmt_listen (default 127.0.0.1:8081). The Chrome hold uses this to publication and update settings astatine runtime without restarting the proxy. You tin besides telephone it straight pinch curl:

Endpoint Method Description
/api/config GET Return progressive settings arsenic JSON, including the existent custom_hello
/api/config POST Update TLS preset (including a afloat civilization custom_hello), customer IP, and User-Agent
# Read existent settings curl http://127.0.0.1:8081/api/config # Switch to Firefox fingerprint and group a spoofed IP curl -s -X POST http://127.0.0.1:8081/api/config \ -H "Content-Type: application/json" \ -d '{"tls_preset":"firefox","client_ip":"203.0.113.1","user_agent":""}' # Switch to an arbitrary JA3/JA4 fingerprint astatine runtime — aforesaid fields arsenic the # config.yaml custom_hello block, sent arsenic JSON (see "Custom TLS fingerprint" below) curl -s -X POST http://127.0.0.1:8081/api/config \ -H "Content-Type: application/json" \ -d '{ "tls_preset": "custom", "custom_hello": { "cipher_suites": [2570, 4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49171, 49172, 156, 157, 47, 53], "curves": ["X25519", "P256", "P384"], "versions": ["1.3", "1.2"], "extensions": [2570, 0, 23, 65281, 10, 11, 35, 16, 5, 18, 13, 51, 45, 43, 27, 21] }, "client_ip": "", "user_agent": "" }'

Changes return effect instantly for caller connections. Set mgmt_listen: "" to disable the API entirely.

Browser fingerprint reference

Browser TLS preset HTTP/2 SETTINGS WINDOW_UPDATE
Chrome chrome 1:65536,2:0,4:6291456,6:262144 15663105
Firefox firefox 1:65536,4:131072,5:16384 12517377
Safari safari 1:4096,3:100,4:2097152,6:16384 10485760

Custom TLS fingerprint (preset: "custom")

The built-in presets (chrome, firefox, safari, …) screen the astir communal cases. When you request to lucifer a circumstantial browser type aliases a fingerprint that differs from those presets, group preset: "custom" and supply a custom_hello block.

How JA3 / JA4 representation to config fields

Fingerprint component Config field Notes
TLS type range versions Min/max are derived automatically
Cipher suite database + order cipher_suites Use 0x0a0a arsenic a GREASE placeholder; uTLS randomises it per connection
Extension type IDs + order extensions Order straight controls the JA3 extensions component; values matching the GREASE shape (0xXAXA) are randomised per connection
Supported groups (curves) curves Also controls which cardinal shares are sent

JA3 and JA4 are one-way hashes — you cannot reverse a hash backmost to a spec. Find the underlying parameters for the target browser pinch tls.peet.ws aliases Wireshark, past paste them into custom_hello.

Chrome 131 example

tls: preset: "custom" custom_hello: cipher_suites: # hex IDs; 0x0a0a = GREASE placeholder (randomised per connection) - 0x0a0a - 0x1301 # TLS_AES_128_GCM_SHA256 - 0x1302 # TLS_AES_256_GCM_SHA384 - 0x1303 # TLS_CHACHA20_POLY1305_SHA256 - 0xc02b # ECDHE-ECDSA-AES128-GCM-SHA256 - 0xc02f # ECDHE-RSA-AES128-GCM-SHA256 - 0xc02c # ECDHE-ECDSA-AES256-GCM-SHA384 - 0xc030 # ECDHE-RSA-AES256-GCM-SHA384 - 0xcca9 # ECDHE-ECDSA-CHACHA20-POLY1305 - 0xcca8 # ECDHE-RSA-CHACHA20-POLY1305 - 0xc013 # ECDHE-RSA-AES128-SHA - 0xc014 # ECDHE-RSA-AES256-SHA - 0x009c # RSA-AES128-GCM-SHA256 - 0x009d # RSA-AES256-GCM-SHA384 - 0x002f # RSA-AES128-SHA - 0x0035 # RSA-AES256-SHA curves: # X25519 | X25519Kyber768 | P256 | P384 | P521 - "X25519Kyber768" - "X25519" - "P256" versions: # TLS versions to advertise - "1.3" - "1.2" extensions: # hold type IDs successful bid (controls JA3 extensions component) - 0x0a0a # GREASE - 0 # server_name (SNI) - 23 # extended_master_secret - 65281 # renegotiation_info - 10 # supported_groups - 11 # ec_point_formats - 35 # session_ticket - 16 # ALPN - 5 # status_request - 18 # signed_certificate_timestamp - 13 # signature_algorithms - 51 # key_share - 45 # psk_key_exchange_modes - 43 # supported_versions - 27 # compress_certificate - 17513 # application_settings (ALPS) - 0x0a0a # GREASE - 21 # padding

Supported hold type IDs

ID Name Notes
0xXAXA (any GREASE pattern) GREASE Randomised per connection
0 server_name (SNI)
5 status_request OCSP stapling
10 supported_groups Uses the curves list
11 ec_point_formats Fixed: uncompressed (0)
13 signature_algorithms Chrome-like defaults
16 ALPN Advertises h2, http/1.1
18 signed_certificate_timestamp
21 padding BoringSSL-style padding
23 extended_master_secret
27 compress_certificate
28 record_size_limit Fixed: 0x4001
35 session_ticket
43 supported_versions Uses the versions list
45 psk_key_exchange_modes PSK pinch DHE
50 signature_algorithms_cert Chrome-like defaults
51 key_share Key shares for X25519 and P256 (from curves)
17513 application_settings (ALPS) Advertises h2
65281 renegotiation_info
other GenericExtension Sent pinch quiet payload

Runtime updates: preset: "custom" is not constricted to config.yaml — it tin besides beryllium switched to astatine runtime via the guidance API (POST /api/config pinch a custom_hello object, spot Management API) aliases from the Chrome extension's TLS Preset dropdown, without restarting the proxy.

make run # Kills immoderate erstwhile lawsuit connected larboard 8080, rebuilds, and starts.

To move fingerprint profiles, edit config.yaml and re-run make run.

# With CA trusted system-wide (after make trust-ca): curl --proxy http://127.0.0.1:8080 https://tls.peet.ws/api/all # Without strategy spot — walk CA explicitly: curl --proxy http://127.0.0.1:8080 --cacert ca.crt https://tls.peet.ws/api/all

The chrome-extension/ directory contains a Manifest V3 hold that controls the proxy from the browser toolbar.

Chrome hold popup

Installation:

  1. Open chrome://extensions successful Chrome
  2. Enable Developer mode (toggle successful the top-right corner)
  3. Click Load unpacked and prime the chrome-extension/ folder

Controls:

Control What it does
Proxy toggle Enables / disables Chrome's proxy mounting (routes postulation done :8080)
TLS Preset Switches the uTLS fingerprint preset (chrome / firefox / safari / separator / ios / random / golang / custom)
Cipher Suites / Curves / TLS Versions / Extensions Shown erstwhile Custom (JA3/JA4) is selected — the aforesaid fields arsenic custom_hello successful config.yaml, letting you dial successful an arbitrary JA3/JA4 fingerprint without editing YAML aliases restarting the proxy
Client IP Sets X-Forwarded-For and True-Client-IP connected each request
User-Agent Overrides the HTTP User-Agent header
Apply button POSTs the caller settings to the guidance API; takes effect immediately
API field Address of the guidance API (default http://127.0.0.1:8081)

User-Agent scope: The hold changes the HTTP User-Agent header only. JavaScript's navigator.userAgent is controlled by Chrome itself and is not affected. To spoof some simultaneously, motorboat Chrome pinch --user-agent="..." alongside the proxy settings.

const { chromium } = require('playwright'); const browser = await chromium.launch(); const context = await browser.newContext({ proxy: { server: 'http://127.0.0.1:8080' }, }); // If CA is not successful the strategy keychain, group earlier launching: // NODE_EXTRA_CA_CERTS=./ca.crt node script.js const page = await context.newPage(); await page.goto('https://tls.peet.ws/api/all');
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() context = browser.new_context(proxy={"server": "http://127.0.0.1:8080"}) page = context.new_page() page.goto("https://tls.peet.ws/api/all")

Set NODE_EXTRA_CA_CERTS (Node) aliases REQUESTS_CA_BUNDLE (Python) if the CA is not trusted system-wide.

tls.peet.ws returns the afloat fingerprint breakdown for immoderate petition it receives. Pipe the output done jq aliases Python for a readable view:

curl -s --proxy http://127.0.0.1:8080 --cacert ca.crt \ https://tls.peet.ws/api/all | python3 -m json.tool

Key fields to check:

Field Description
tls.ja3_hash JA3 fingerprint hash
tls.ja4 JA4 fingerprint string
http2.akamai_fingerprint HTTP/2 fingerprint drawstring (SETTINGS + WINDOW_UPDATE + pseudo-header order) — section sanction is defined by the tls.peet.ws API
http1.headers Header names successful the bid received by the server
user_agent User-Agent arsenic seen by the server
ip Source IP arsenic seen by the server — verify X-Forwarded-For / True-Client-IP spoofing here
impersonate-proxy/ ├── main.go # Entry point ├── config/config.go # YAML config struct and defaults ├── fp/dialer.go # uTLS dialer — TLS fingerprint presets ├── h2fp/conn.go # HTTP/2 framer — SETTINGS / WINDOW_UPDATE / pseudo-header control ├── mitm/ca.go # MITM CA: generate, cache, and service leafage certs ├── proxy/proxy.go # Proxy server: CONNECT handling, protocol branch, runtime config ├── rewrite/headers.go # HTTP header rewriting (UA, order, add/remove, IP spoof) ├── mgmt/server.go # Management HTTP API (/api/config GET + POST) ├── chrome-extension/ │ ├── manifest.json # Manifest V3 │ ├── popup.html # Toolbar popup UI │ ├── popup.css │ ├── popup.js # Proxy toggle + guidance API client │ └── icon.svg ├── config.yaml # Default configuration └── Makefile
Target Description
make build Compile the binary
make run Build, termination immoderate existing instance, and start
make trust-ca Add ca.crt to the macOS strategy keychain (requires sudo)
make clean Remove the binary, ca.crt, and ca.key

Remove the binary and generated CA files:

If you added the CA to the macOS strategy keychain, region it done Keychain Access (search for "impersonate-proxy CA") or:

sudo information delete-certificate -c "impersonate-proxy CA" /Library/Keychains/System.keychain
  • MITM only: The proxy decrypts and re-encrypts traffic. Clients must spot the generated CA.
  • No HTTP/2 from client: The client→proxy limb uses HTTP/1.1 (via CONNECT). Only the proxy→server limb uses HTTP/2 pinch civilization fingerprints.
  • Chunked petition bodies: Requests pinch Transfer-Encoding: chunked bodies are not presently supported.
  • No QUIC / HTTP/3: Out of scope.
  • User-Agent (HTTP header only): The proxy rewrites the User-Agent HTTP header, but JavaScript's navigator.userAgent is group by the browser independently and is unaffected. Use Chrome's --user-agent motorboat emblem to override some simultaneously.

This instrumentality is intended for authorized information testing only — for example, testing WAF and bot-detection configurations connected systems you ain aliases person definitive written support to test.

Using this instrumentality against systems without authorization whitethorn break applicable laws (such arsenic the Computer Fraud and Abuse Act, Japan's Unauthorized Computer Access Law, aliases balanced authorities successful your jurisdiction) and the position of work of the target.

The authors judge nary liability for misuse.

  • uTLS — TLS fingerprint customization
  • tls.peet.ws — Fingerprint inspection API utilized successful examples
  • JA4+ — Fingerprinting modular reference
More