pybrws
Making Python look like a browser
Three write-ups about a thing I did not know existed until a scraper of mine started getting 403s it should not have.
Short version of how this started: a URL that a plain Python client gets a
403 on loads fine in Firefox, on the same machine, over the same
connection. I copied the browser's headers out of devtools until a
field-by-field diff of the two requests came back empty, and it was still
a 403. So the server was not reacting to anything in the request.
It had decided earlier — from the shape of the TLS handshake, before
any HTTP exists, which is not something you can change from Python's
ssl module.
So I wrote the handshake. Then TLS 1.2 as well, then HTTP/1.1, HTTP/2, and eventually HTTP/3 over QUIC, with profiles for Firefox 157 and Chrome 155 built out of my own packet captures. These three posts are the parts worth telling, including the bits I got wrong.
Making Python impersonate Firefox
1,901 bytes, 70% of them one extension I could not simply copy. Which fields are allowed to vary and how I found out. And the afternoon I discovered my own antivirus had been rewriting the browser's fingerprint the whole time.
PART TWOImpersonating Chrome, which never sends the same thing twice
Six captures, six different extension orders, and two GREASE values pinned at the ends that collide one time in sixteen. Then a day of failed hypotheses on a site that still refused me, and the answer sitting in a sentence I had already been given.
PART THREEFirefox has two TLS fingerprints, and they disagree
QUIC handshakes can be decrypted by anyone, with a key printed in the RFC. I read my own network for a few minutes and found that Firefox shuffles over QUIC and not over TCP — plus three readings I had to take back.
The client itself
from pybrws.client import get
r = get("https://example.com", profile="chrome155")
| Layer | What is in it |
|---|---|
| TLS | 1.2 and 1.3 — handshake, records, key schedule, resumption, HelloRetryRequest, client certificates |
| Transport | HTTP/1.1, HTTP/2, HTTP/3 over QUIC — each sending its profile's own settings, frame order and header order |
| Plumbing | cookies, sessions, connection pool, proxies, redirects, streaming, content decoding |
| Profiles | Firefox 157 over TCP, Firefox 157 over QUIC, Chrome 155 — each measured separately |
It is built on cryptography for the primitives and
aioquic for QUIC's transport, and nothing else. Everything
whose shape is a fingerprint is written here.
If you just want working requests, use curl_cffi — it links the actual libraries browsers use, and it is faster and more complete than this will ever be. I wrote this to understand the problem, and because when a target starts keying on something your library does not reproduce, somebody has to be able to work out what.
It does not run JavaScript, so a "checking your browser" page is out of reach by design. That boundary comes up in part two and it is the honest limit of all of this.