NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
Go modules · #1504 by repository stars
Last release 1 months ago
02 Sep 2026
Release timing varies
gaps range from 8 days to 2 months
Some releases are documented
notes for 12 of 38 stable releases
4 versions withdrawn
withdrawn after publishing
9 months old
97 releases · first in 2025
One column per month.
Fixes HTTP/3 fingerprinting for presets built from a captured ClientHello or a JA3, which were silently not applying over HTTP/3 at all.
Fixes HTTP/3 fingerprinting for presets built from a captured ClientHello
or a JA3, which were silently not applying over HTTP/3 at all.
A preset carries one capture and one JA3, and which transport those
describe is a property of the bytes: every QUIC hello carries
quic_transport_parameters and no TCP one does. Setting either cleared the
preset's QUIC identity, so the HTTP/3 transport had nothing to build from
and fell back to the QUIC stack's own default hello. Requests still
succeeded, carrying a fingerprint nobody chose and a worse one than the
base preset. Measured against a live endpoint, chrome-151-windows over
HTTP/3 dropped from 11 extensions to 7 purely from adding a TCP ja3.
Also in this release:
HTTP/3 resolves its hello from every fingerprint source rather than one,
each gated on whether it describes that transport, and an unusable
source is an error instead of a silent fall-through.
allow_blunt_mimicry no longer freezes the extension order. Everything
whose position is constrained stays pinned regardless, so the two can
compose, which matters most on HTTP/3 where a capture could not be read
without it.
A captured QUIC hello no longer needs allow_blunt_mimicry at all, so the
rest of the capture keeps per-extension validation instead of being
passed through unchecked.
Forks: net v1.2.10, quic-go v1.2.29, utls v1.10.5.
Worth taking if you use HTTP/3 with a custom fingerprint. A preset built from a captured hello or a JA3 was not applying its fingerprint over HTTP/3 at all, and the requests succeeded anyway, so there was nothing to notice.
A preset using raw_client_hello or ja3 lost its fingerprint on HTTP/3: a preset carries one captured hello and one JA3, and which transport those describe is a property of the bytes, since every QUIC hello carries quic_transport_parameters and no TCP one does. Setting either cleared the preset's QUIC identity on the assumption that the capture was the whole fingerprint, which holds for TCP and is false for QUIC. The HTTP/3 transport then had nothing to build from and fell back to the QUIC stack's own default hello, so every request still succeeded while carrying a fingerprint nobody chose, and a worse one than the preset it inherited from. Measured against a live endpoint, chrome-151-windows over HTTP/3 went from 11 extensions to 7 purely from adding a TCP ja3. The QUIC identity now survives a TCP-only source, and whether a capture applies to QUIC is decided per transport rather than baked into the preset.
HTTP/3 now resolves its hello from every fingerprint source, not just one: the QUIC transport knew only the named QUIC client hello id, while HTTP/1.1 and HTTP/2 had moved to a shared resolver, so a captured QUIC hello could never be used. Both transports now go through the same resolution, each source gated on whether it describes that transport, so a QUIC capture is used on QUIC and a TCP capture is not. A source that cannot be used is an error rather than a silent fall-through, which is what let the original problem stay invisible.
allow_blunt_mimicry no longer freezes the extension order: the two were mutually exclusive, so declaring permute_extensions alongside it was ignored rather than refused. Everything whose position is genuinely constrained is modelled and stays pinned regardless: GREASE brackets the list, padding sizes the record, and pre_shared_key is required to be last. Only extensions that cannot be modelled at all are passed through opaquely, and those carry no ordering constraint. This mattered most on HTTP/3, where a capture could not be read without blunt mimicry, so every HTTP/3 mirror was frozen at a single extension order however the preset was written.
allow_blunt_mimicry: quic_transport_parameters was the one extension the TLS layer recognised without being able to parse, and blunt mimicry passes every unrecognised extension through unchecked, so that single gap switched off per-extension validation for the whole capture with no way to decline it. It is now parsed, and the captured bytes are kept exactly as they arrived rather than re-encoded, because the encoding allows several byte strings for the same meaning and for a fingerprint the bytes are the meaning. Nothing captured reaches the wire: transport parameters are specific to the connection that sent them, so the QUIC layer substitutes its own.A patch release: everything here is additive or a fix, nothing changes behaviour for existing code, and the .NET assembly is once again a drop-in over
A patch release: everything here is additive or a fix, nothing changes
behaviour for existing code, and the .NET assembly is once again a drop-in
over both 1.6.8 and 1.7.0.
Added
TrimMemory, which returns freed memory to the operating system. Closing
a session makes its memory collectable, which is a different thing from
handing it back, and nothing did the second part.
Response trailers, response header order and header casing, all recorded
by the core and reaching no binding until now.
Per-request header order, which the core has accepted for a while and no
binding could set.
Streaming without blocking the event loop, in Python.
exact_headers in Node and .NET.
HTTPCLOAK_LIB_PATH, which points one process at a specific build.
Session.CloseGraceful.
Changed
The .NET assembly resolves the 1.6.8 and 1.7.0 method shapes again, so a
deployment that cannot rebuild every consumer can take this build. A
public API baseline now runs on every push so it cannot regress.
A session bound to a local address no longer resolves the family it
cannot dial.
Fixed
A captured ClientHello that declares its client permutes now permutes on
every handshake rather than once per session.
Repeated header names keep their position in exact-headers mode.
The caller's header map is no longer merged over an exact-headers request.
An unknown preset name errors instead of silently serving an old one.
permute_extensions works on the captured-hello path.
disable_redirect_referer is honoured everywhere it is accepted.
A closed HTTP/2 connection is collectable immediately.
A transient failure on the first request no longer pins a host to HTTP/1.1.
Forks: net v1.2.10, quic-go v1.2.29, utls v1.10.4.
TrimMemory returns freed memory to the operating system: closing a session makes its memory collectable, which is a different thing from handing it back. Go releases pages lazily and on Linux does so in a way that leaves them counted against the process until the kernel wants them, so resident memory stays flat long after the sessions are gone. Measured over 150 sessions each doing a real TLS request: 85MB resident, and closing every one of them then forcing a collection moved it by under three megabytes, in the wrong direction. TrimMemory runs the scavenge to completion and returned 61MB of it. This is a ceiling rather than a leak, bounded by how many sessions are alive at once, so reach for it when the ceiling itself is the problem: a worker that has finished a batch and will now idle, a memory-capped container, or a process-per-job model where resident size is what gets measured. It is deliberately not part of Close, because a full scavenge stops the world and a pool closing sessions steadily would pay that on every close. Available as httpcloak.TrimMemory(), trim_memory(), trimMemory() and HttpCloakInfo.TrimMemory().
Response trailers, header order and header casing: the core recorded all three and none of them reached a binding. trailer carries the block sent after the body, which is where gRPC puts its status. header_order is the order the peer sent its headers in and header_casing is how it spelled them, which together let a proxy or bridge reproduce a response header block exactly, something a map cannot do. Order comes from HTTP/2 and HTTP/3, which decode an ordered field list; casing comes from HTTP/1.1, the only protocol where field names are not lowercase by definition. Streamed responses get trailer() as a method rather than a field, since on a stream the trailers have not arrived when the response opens.
Per-request header order: the core has accepted a per-request override for a while and no binding could reach it, so callers were left setting a session-wide order around a request, which races with everything else in flight. It stores nothing on the session, so concurrent requests can each carry their own, and it is a prefix rather than a replacement: names listed go first, in that order, and anything left out keeps the preset's own position.
Streaming without blocking the event loop, in Python: every streaming method blocked the calling thread until the response headers arrived, which in an asyncio program stalls every other coroutine for the length of a DNS lookup, a connect and a TLS handshake. request_stream_async, get_stream_async and post_stream_async await that instead. Against a server stalling before its headers, three concurrent opens now finish in the time of one. Only the opening is asynchronous; the body still reads synchronously.
exact_headers in Node and .NET: the C interface has carried it since 1.7.0 and only Python wrapped it, so a Node or .NET caller had no way to reproduce a captured request at all.
HTTPCLOAK_LIB_PATH selects the shared library, in every binding: a deployment running many processes out of one shared directory had no way to stage a rollout, since every binding searched relative to the installed package. Pointing a single process at a specific build gives back the per-process choice without splitting the fleet into separate folders. Python and Node already read the variable but mishandled a directory; .NET did not read it at all.
Session.CloseGraceful closes a session without cutting off requests still in flight: Close tears the session's connections down at once, which is right for shutdown but wrong for rotating a long-lived session, because any response body still being read on one of its connections is interrupted with "use of closed network connection". Anyone rotating sessions therefore had to guess a grace period longer than their longest possible request and sleep it out before calling Close. CloseGraceful stops the session taking new requests immediately (they fail with ErrSessionClosed, exactly as after Close), closes every idle connection now, and lets each connection that still has a response streaming on it finish that response first, closing it the moment the body is done. It returns without waiting; the draining happens in the background. This reuses the deferred close that 1.6.9 introduced for #83, applied to the whole HTTP/2 pool instead of to one evicted connection, and the pool's cleanup pass keeps running until the last such connection is gone so that a body which is never closed is still reclaimed by the abandoned-body bound rather than pinning its socket. HTTP/1.1 connections are only ever closed while idle, so they already behaved this way; HTTP/3 connections are closed immediately, as by Close, since the QUIC layer has no per-request drain. Close after CloseGraceful is not a no-op: it forces whatever is still draining. Close itself is unchanged.
The .NET assembly is a drop-in over 1.6.8 and 1.7.0 again: C# writes a call's full signature into the calling assembly, so adding an optional parameter to a shipped method leaves every already-compiled caller looking for a method that no longer exists, failing at the call rather than at load. Two releases did that, which left anyone unable to rebuild every consumer stuck on the version they had, even though nothing they used had changed behaviour. Every shape that went missing is back as a forwarding overload that does nothing but call the current method, so binaries compiled against either release run against this one unchanged. Nothing was removed and no behaviour moved. A public API baseline now runs on every push, so a future release cannot break this by accident.
A session bound to a local address no longer resolves the family it cannot dial: WithLocalAddress fixes the address family for every connection a session makes, because a socket bound to an IPv6 source cannot reach an IPv4 peer and vice versa. All three transports already knew this and dropped the unusable records after resolving them, each filtering the resolved set against the bound family before dialing and erroring with "no IPv6 addresses found for host" when nothing survives. The resolver was never told, so it was asked for both families on every lookup and half the answer was thrown away.
getaddrinfo turns an unrestricted lookup into an A query and an AAAA query, so that was two queries per lookup where one would do. Sessions are commonly built per connection when rotating source addresses, and each new session starts with a cold cache, so the waste scales with connection rate rather than with host count: at 200 new connections a second, one per five milliseconds, it is 200 queries a second for records that are discarded on arrival. Measured on Linux with the CGO resolver against a local systemd-resolved, sustaining the lookups alone with no connection attached, the discarded half cost roughly 270 microseconds of CPU each, about two thirds in the calling process and one third in the resolver. That is close to five percent of a core at 200 lookups a second, and around ten points of a core at both 500 and 1000.
dns.Cache now takes an address family via SetNetwork("ip4" | "ip6" | ""), and NewTransportWithConfig sets it from LocalAddr using the new dns.NetworkForLocalAddr helper. A restricted cache resolves through net.Resolver.LookupIP with that network instead of LookupIPAddr, which is what keeps the query off the wire rather than filtering the answer afterwards. Sessions with no local address are untouched and still query both families.
The restriction is only applied where one address is known to hold for the whole transport, which is at construction. The three protocol transports share a cache but keep their own local address, so a later SetLocalAddr on one of them reaches a cache its siblings are still filtering against: rebinding within the current family keeps the restriction, and anything else lifts it and resolves both families again, so no configuration reachable that way is worse off than before. Cached entries are stamped with the family they were resolved under and are not served while the cache is on another one, so a rebind cannot hand back a set the new family would filter down to nothing, and neither can a lookup that was already in flight when the family changed.
Nothing that previously connected stops connecting: the records no longer resolved are exactly the ones the dial paths were already refusing. The one visible difference is the error for a host that publishes no record of the bound family, which now comes back as a resolver error naming the host instead of a dial error reporting no usable address; it was a failure before this change too. SetNetwork ignores any value other than the three above, so a bad string cannot quietly turn every lookup into an error, and it does not re-resolve entries already in the cache, so change it before use or follow it with Clear.
A captured ClientHello that declares its client permutes now permutes on every handshake: it was shuffled from a seed drawn once per transport, so every connection in a session repeated one order, and the HTTP/1.1 path passed a constant, which repeated one order for every process on every machine. Every other path already drew fresh randomness per connection, which is what the browsers that permute do. This affected presets built from a captured hello only; the named presets were always correct.
Repeated header names keep their position in exact-headers mode: the order list held one entry per name and every encoder read it as "emit all of this name's values here", so a captured request carrying two of one name either side of a third came out with them adjacent. Each pair now takes its own slot across HTTP/1.1, HTTP/2 and HTTP/3.
The caller's header map is no longer merged over an exact-headers request: the documented contract was that nothing unlisted reaches the wire, and the merge ran unconditionally at every request-building site. The session writes its cookie jar into that map, so a request built to mirror a capture went out with a jar cookie appended to it.
An unknown preset name is an error instead of a silent downgrade: a typo returned a two-year-old fingerprint and a 200, with nothing in the logs. It now fails with the closest matching name.
permute_extensions works on the captured-hello path: it was read from a field that path never consults, so declaring it did nothing.
disable_redirect_referer is honoured everywhere it is accepted: in .NET all 33 forwarding calls dropped it, so it worked only when the four generic methods were called directly, and setting it alone serialised no options at all. In Node two entry points accepted it and never wrote it into the request.
A closed HTTP/2 connection is collectable immediately: a never-reused connection was parked for five seconds after close so a fresh connection's error could still surface. That reasoning covers a connection that fails by itself, not one the caller closed, and it held the whole connection and its buffers for the duration. A thousand create-request-close cycles went from 99MB resident and 49MB of live heap to 25MB and 1.2MB, flat with cycle count.
A transient failure on the first request to a host no longer pins that host to HTTP/1.1 for the rest of the session: in auto mode the transport learns which protocol a host speaks and caches it per session, so later requests skip the negotiation (added for #68). Two different facts were being written into that cache under the same key. "This host negotiated http/1.1 via ALPN" is a property of the host and is right to cache. "The HTTP/2 attempt failed this time and the HTTP/1.1 fallback got the request through" is a property of one attempt: a reset or timed-out handshake, a first dial that did not survive, anything transient. That second case was cached exactly like the first, and only the very first request to a host (or the first after Refresh) could hit it, because once a host is known as HTTP/2 a later transient failure serves one request over HTTP/1.1 without touching the cache. So the failure mode was silent and permanent: one bad handshake on the opening request and every following request to that host went out over HTTP/1.1, and with it a different fingerprint, with nothing that would ever re-probe. Long-lived sessions and anything that creates sessions frequently (each new session starts with an empty cache) were the most exposed. The fallback still happens and the request still succeeds; it just no longer writes the cache, so the next request attempts HTTP/2 again and, when that works, the host is cached as HTTP/2 like any other. ALPN downgrades are cached as before, so an HTTP/1.1-only host still pays the failed HTTP/2 attempt only once. Forced protocols bypass the cache entirely and are unaffected, and so is what each request does on the wire: the fallback runs exactly as before, only the cache write is gone.
Nothing published for this version
Nothing published for this version
Chrome 152, and a release cycle's worth of wire-accuracy work.
Chrome 152, and a release cycle's worth of wire-accuracy work.
Chrome 152 is the first version in a while whose ClientHello actually moved. It
adds the trust_anchors extension, carrying 28 certificate authority identifiers
in an order that reshuffles on every connection, and a greased signature
algorithm at the head of the list on TCP. Both are reproduced, and both are
reachable from a JSON preset. The chrome-latest aliases now track 152, iOS
included, which is built from real captures rather than derived.
Header ordering now follows the request shape. Chrome builds a navigation and a
subresource through different paths and the leading block differs, so every
request that was not a page load went out with a page load's ordering.
Presets can be built from a captured ClientHello, with the option to permute its
extensions per connection the way Chromium does. Exact-headers mode reproduces a
captured request without the library adding anything to it. Responses now carry
their trailers, the order the server sent its headers in, and on HTTP/1.1 the
casing it used. The preset loader can be asked to refuse a file with a key it
does not model, which until now was the quietest way to ship the wrong
fingerprint.
Plus a long list of fixes to HTTP/2 flow control and framing, HTTP/3 GREASE and
transport parameters, TLS record sizing, and resumption on HTTP/1.1, which never
worked at all.
Full notes in CHANGELOG.md.
Chrome 152 preset family: chrome-152 plus the chrome-152-windows / -linux / -macos variants, chrome-152-android, and chrome-152-ios. All chrome-latest* aliases now resolve to 152. Unlike the last few version bumps this is a real wire change rather than a header refresh, in two places.
The first is a new TLS extension carrying the set of certificate authorities the client is willing to accept, sent as a list of short identifiers. Chrome 152 advertises 28 of them in a 184-byte list. The order is not stable: it comes from iterating a hash container whose seed is regenerated whenever the configuration is copied, which happens once per connection, so a browser sends the same set in a different sequence on every handshake. The preset reproduces that with a fresh permutation per connection rather than a fixed order, because a fixed order is the one thing a real client never produces.
The second is a placeholder value at the head of the signature algorithm list on TCP connections, drawn fresh per handshake. This one is worth knowing about because it makes the third component of the JA4 hash vary per connection against any implementation that does not discard placeholder values everywhere it meets them, which the JA4 specification asks for but not every implementation does. That is what a real Chrome 152 does, so the preset does it too. Connections over QUIC advertise nine signature algorithms with no placeholder and are unaffected.
chrome-152-ios carries neither, because it is built on the platform's own TLS stack rather than Chromium's and only its user agent moves.
raw_client_hello: build a preset from a captured handshake: a preset can now carry the bytes of a real ClientHello, base64-encoded, instead of describing one field by field. Everything the capture contains goes on the wire as captured, including extensions the library has no model for, which is what a field-by-field description cannot express. raw_psk_client_hello carries the resumption-shaped variant of the same client so that a session which resumes sends the hello that client actually sends when resuming, rather than a first-handshake shape with a session ticket bolted on. Captures are validated when the preset loads, so an unusable one is a named error at load time instead of a handshake failure on the first request. Unrecognised extensions need tls.allow_blunt_mimicry, and the error says so.
trust_anchors and quic_connection_options as preset keys: both are JSON-configurable, both round-trip through describe_preset byte-equal, and both are emitted only when a preset actually sets one, so describing a preset that does not use them no longer invents a value. quic_connection_options defaults to what current Chrome sends; making it a key rather than a constant means a change in that value is a preset edit rather than a release.
Exact-headers mode: ExactHeaders replaces the entire header pipeline for one request. The pairs go on the wire in the order and the casing given, a name may repeat at a chosen position, and nothing else is added: no preset block, no client hints, no Sec-Fetch-* inference, no alphabetical tail for names the preset does not know. It exists for reproducing a captured request verbatim, which the normal path cannot do because it is opinionated in exactly those three ways and because a map[string][]string cannot express two headers of the same name in a chosen position. Host and Connection on HTTP/1.1 and the pseudo-header block on HTTP/2 and HTTP/3 are still written for you, since those are protocol framing rather than caller headers.
DisableRedirectReferer: turns off the Referer that is otherwise synthesised on each redirect hop. The default stays on and follows the browser's own policy, the full previous URL same-origin, the origin alone cross-origin, nothing at all on an https-to-http downgrade, so leave it off unless you are reproducing a client that sends none. When set, no Referer reaches the next hop at all, including one set on the original request, because forwarding that would hand the pre-redirect URL to the new host.
Response.Trailer: the trailing header block a server sends after the body, lowercase-keyed, nil when there was none. It matters for gRPC, where the call's real status arrives in the trailers and the response is a 200 either way, so a client that ignores them reports every failed call as a success. Buffered responses carry it as a field, since the body is read before the response is built. Streamed ones expose Trailer() instead, because the trailing block has not arrived when the header block does and a field would hold the declared names with no values; call it after the body reaches EOF.
Response.HeaderCasing: the response header names as the server spelled them, HTTP/1.1 only. HTTP/2 and HTTP/3 require lowercase on the wire so there is nothing to preserve there, but on HTTP/1.1 the parse underneath canonicalises (X-FOO is reported as X-Foo), and anything relaying a response onward would emit a spelling the origin never used. Best-effort: nil when the header block was not fully buffered by the time the response was read, since correct casing is not worth blocking a response for.
LoadPresetFromJSONStrict and UnknownPresetFields: strict preset loading. The loader ignores keys it does not recognise, which is right for forward compatibility and wrong for a typo, and this is the quietest failure it has: a misspelled key does not fail, it leaves that part of the preset at whatever the inheritance chain supplied, so a file written to mirror one client can go on the wire as another with no error anywhere. UnknownPresetFields reports every unmodelled key as a dotted path; the strict loader refuses such a preset outright. The lenient loader stays the default. Free-form maps end the walk, so custom header names are not reported as typos.
permute_raw_hello: lets a preset built from a captured ClientHello shuffle its extension order per connection, the way Chromium does. It has to be declared rather than detected, because a capture is one connection and its extension order is one sample: nothing in the bytes says whether that client would have ordered them differently next time. Chromium permutes; NSS, Apple's stack and Go do not. Ignored when allow_blunt_mimicry is set, since extensions the library has no model for cannot be safely moved.
httpcloak_stream_request_async: an async streaming entry point in the C ABI, for Python, Node and .NET. The synchronous one blocks the calling thread until the response headers arrive, which for a stream can be the whole point of the request, so opening several long-lived streams cost a thread each and a single-threaded runtime could not open one without stalling. The callback carries the same metadata the synchronous path returns, with the stream handle added.
HPACK representation control per header name: a profile can pin how an individual header is encoded, which is what makes it possible to match a browser's compression instructions rather than only its header list.
Response header order: Response.HeaderOrder reports the order the server sent its headers in, one entry per occurrence, lowercased. A header map cannot carry order, so a caller relaying a response onward would otherwise emit a different sequence than the origin did. HTTP/2 and HTTP/3 record it for free; HTTP/1.1 reports nil, because the parser underneath canonicalises names and discards order.
Resumption is observable: whether a connection resumed a previous TLS session is now reported rather than inferred.
Exact-headers mode added two headers the caller never listed. Its contract is that nothing reaches the wire but what was given, and Connection went out on every HTTP/1.1 request while accept-encoding could go out twice on HTTP/2.
Connection is right on the normal path, since Chrome sends it on every HTTP/1.1 request, and wrong for a caller reproducing a capture that carries none. Nothing is lost by leaving it out: HTTP/1.1 keeps connections alive by default, so the header restates the default rather than causing it. A caller who lists it still gets exactly what they asked for.
The duplicate accept-encoding was the more interesting one. The gzip decision looked the header up under its canonical map key, but exact-headers stores names as the wire carries them, and HTTP/2 carries them lowercased, so a request already carrying accept-encoding was read as carrying none and had a second one appended. Two of the same header on the wire, which no browser sends.
Every request went out with a top-level navigation's header order, including the ones a browser never builds that way. Chrome constructs a navigation and a subresource through different paths and the leading block comes out differently: a navigation leads with the three low-entropy client hints, then upgrade-insecure-requests, then user-agent; a subresource leads with the platform hint and user-agent, then the other two hints, carries a Referer, and sends neither upgrade-insecure-requests nor sec-fetch-user. A preset carried one order and used it for everything, so anything that was not a page load, which for most callers is nearly every request, went out with an ordering the browser only produces for page loads.
What made it worth fixing is that the rest of the request was already right. The header set, the Accept value per destination, the Sec-Fetch-* values and the per-resource priority were all correct, so the ordering was the only part left saying the request had not been built the way a browser builds one, which is a worse signal than being uniformly wrong.
A profile can now carry a second order for non-navigation requests, chosen per request from the destination the request already computes. Measured across twenty requests covering script, style, image, font, manifest and a fetch(), all six produce a single order and only the navigation differs, so this is a two-way split rather than one order per resource type. Three headers appear conditionally and now have reserved slots rather than landing in the alphabetical tail: origin and the pragma / cache-control pair at the front, and sec-fetch-storage-access between sec-fetch-dest and referer. Presets that describe a client using one order for everything are unaffected, since the field is optional and absent means "use the one order for both".
Forced HTTP/1.1 contradicted itself on Safari and iOS presets: the mode rewrites the offered protocol list down to HTTP/1.1 alone, but left the application-settings extension advertising HTTP/2. A client that says "I only speak HTTP/1.1" in one extension and "here are my HTTP/2 settings" in the next is describing a client that does not exist. The settings extension is now filtered to protocols the offer actually contains and dropped entirely when that leaves it empty. The two construction sites that had drifted apart are now one helper, which is why only one of them had the bug.
describe_preset silently dropped the signature algorithm override: a describe-then-load round trip of any preset carrying one lost every algorithm in it, so a preset rebuilt from its own description advertised a different list than the original. Nothing errored; the output simply had one fewer field than it needed.
HTTP/2 flow control, frame sizing and record sizing did not match a browser, in three ways that had to be fixed together because fixing any one alone leaves the profile more distinctive than it started.
Record sizes ramped. The TLS layer underneath grows its record size as it sends, trading latency for throughput, and record lengths sit in cleartext in the record header where anyone can read them without decrypting anything. One measured upload produced 3580, 4766, 5952, 2174, 9510, 6918, 11882, 4546, 14254, 2174. That is an arithmetic progression, and it does not merely say "not a browser", it names the TLS stack. The ramp is now off on every TCP path.
Frame sizes told the same story from the other end. A full 16384-byte DATA payload becomes a 16393-byte frame once its header is added, which the record layer then splits into one full record plus a 31-byte tail, so a large upload goes out as 16406, 31, 16406, 31 for its whole length. Chromium caps DATA payloads at 16375 precisely to avoid that, and ignores whatever maximum the peer advertised while doing it. The cap is derived per profile rather than applied globally, because Firefox and the WebKit family use the full 16384 and a blanket cap would hand one browser's framing to all the others.
Window updates were emitted on a cadence of our own rather than the browser's, which is visible in the timing and size of every update on a long-lived connection.
The HTTP/2 retry replayed blindly: a failed request was re-sent without asking why it failed, so a request the server had already begun processing could be sent twice. Retries are now classified, and only those the protocol says are safe to repeat are repeated.
The HPACK encoder advertised one table size and used another: the encoder was pinned to the size we advertise rather than the size the peer allows, so the compression state diverged from what the peer was tracking.
HTTP/3 control-stream and transport-parameter values were more predictable than the real thing: the greased setting on the control stream is now drawn the way the reference implementation draws it, deriving both its identifier and its value from independent draws rather than from a fixed pattern; the greased QUIC version label is built from four independent nibbles rather than a single choice; and the greased transport parameter is now genuinely random. A related assertion that the greased version had to come first has been removed, because it does not.
The QUIC path stopped probing for the path MTU, which is not something the browser does, and the QUIC configuration is now built in one place instead of three that had drifted.
A resumed 0-RTT attempt repeated the attempt that had just failed: when 0-RTT was rejected the retry went out identical to the request that had been refused.
SetDisableECH was ignored on the HTTP/3 probe paths: a session that had explicitly disabled encrypted client hello still sent it when the H3 probe ran, so the opt-out held everywhere except the one path most likely to run first.
HTTP/1.1 never resumed a TLS session at all. It is now able to, and a preset defined by a JA3 string can resume too. The old check asked whether the JA3 contained the pre-shared-key extension, which is only ever true for a string captured mid-resumption, so a first capture always reported no support.
The multipart boundary named this library. Every request with a multipart body carried the product name in a field no browser fills that way, in all four language bindings.
JA3Extras had unreachable fields, and unknown signature algorithms were dropped rather than passed through.
Connection timing was partly derived rather than measured, so the reported breakdown of a connection was arithmetic rather than observation.
A connect race with both probes failed hung instead of returning the failure.
ExactHeaders went out alphabetically on HTTP/1.1, and was silently discarded on a redirect hop. The first was a lookup that assumed the canonical spelling of every header name, which exact-headers mode deliberately does not use, so every name missed the ordered pass and fell into a sorted remainder. A caller asking for user-agent, accept, x-mirror got accept, user-agent, x-mirror. The second rebuilt the follow-up request from a fixed field list that did not include it, so a caller mirroring a captured request got their exact bytes on the first request and the full preset pipeline on every hop after it, with no error. For a feature whose entire purpose is byte-exact reproduction, both defeated the point.
A preset that cannot serve a request is now rejected when it loads, rather than failing on first use.
The Python binding used two different names for the JSON body and could crash on the fast path.
The C ABI's int64 entry points returned a bare -1, with no way to ask what went wrong. They now explain themselves.
A prerelease published to npm took the latest tag. npm has no notion of a prerelease version, so publishing one claimed the default install for every user while the other two registries correctly held theirs back. Prereleases now publish under their own tag.
Nothing published for this version
Nothing published for this version
Chrome 152 now defaults to the population that does not GREASE signature_algorithms, which keeps JA4 stable against scorers that do not strip GREASE f
Chrome 152 now defaults to the population that does not GREASE
signature_algorithms, which keeps JA4 stable against scorers that do not strip
GREASE from the sigalg list. Flag-on remains available by putting 2570 back at
the head of signature_algorithms in a JSON preset.
httpcloak.version() reported 1.6.11 from every binding in beta.2 because the C
ABI had the version hardcoded. Fixed.
Same library as v1.7.0-beta.1, which published no bindings because their version files were never bumped off 1.6.11. This one carries the binding vers
Same library as v1.7.0-beta.1, which published no bindings because their version
files were never bumped off 1.6.11. This one carries the binding versions and the
npm dist-tag fix, so the Python, Node and .NET packages actually ship as
prereleases.
pip install httpcloak==1.7.0b2
npm install httpcloak@beta
dotnet add package HttpCloak --version 1.7.0-beta.2
None of them become the default install: pip and NuGet skip prereleases, and npm
now receives an explicit beta dist-tag.
Adds chrome-152 desktop (windows/linux/macos), android and ios presets, and repoints the chrome-latest aliases at 152.
Adds chrome-152 desktop (windows/linux/macos), android and ios presets, and
repoints the chrome-latest aliases at 152.
Chrome 152's two wire changes are the trust_anchors extension (0xCA34,
draft-ietf-tls-trust-anchor-ids) carrying 28 anchor IDs in a 184-byte list
reshuffled per connection, and a GREASE codepoint leading signature_algorithms
on TCP only. QUIC keeps 9 signature algorithms with no GREASE and no ML-DSA.
Both are reachable from JSON presets via trust_anchors and signature_algorithms,
and quic_connection_options is now a preset key defaulting to ORIG.
Verified on the wire against tls.peet.ws and tls3.peet.ws with the published
fork tags and no local replacements:
chrome-152 h2 ja4 t13d1517h2_8daaf6152771_cb7bf5808d99
chrome-152 h3 ja4 q13d312_55b375c5d22e_178839b6cec1
chrome-151 h2 ja4 t13d1516h2_8daaf6152771_806a8c22fdea, unchanged
Prerelease on purpose. go get module@latest skips it, so v1.6.11 stays the
default until this is promoted.
HTTP/2 header compression now matches Chrome on the wire.
HTTP/2 header compression now matches Chrome on the wire.
The header list going out was already correct, so nothing that inspects
headers could see any of this. The HPACK instructions carrying that list were
not Chrome's, in four ways, three of which fired on the very first request.
All four verified byte for byte against captures of the browser itself rather
than against a specification. A connection that gets reused now settles into
the same small, fully-referenced header block a browser produces and holds
there, measured flat over sixty consecutive requests.
Also in this release: a per-hop redirect callback, 307 and 308 no longer lose
the request body through the root session, and a data race on the pooled
connection use counter.
Two things to know. Anyone recording the exact bytes of an outgoing header
block will see them change. And compression state is now shared across requests
on a connection the way a browser shares it, which is what makes the small
blocks possible; the never-indexed list is still configurable per profile, it
simply no longer defaults to being populated.
TLS is untouched. JA4 and the HTTP/2 settings fingerprint are unchanged.
Full notes in CHANGELOG.md.
Request.OnRedirect decides each redirect hop before it is taken: redirect policy was two scalars, follow-or-not and a cap, so a caller who needed to stop on one particular hop had only one option — turn following off and re-implement the chain, including the method rewrite, the Referer policy, the cookie jar and the credential scrubbing that make following a redirect correct in the first place. The Request types in the root, client and transport packages now carry an OnRedirect func(*Redirect) error that is called once per hop before the follow-up request is built. Return nil to follow it, ErrUseLastResponse to stop the chain and get the 3xx back as the response with a nil error, or any other error to fail the request — that error comes back unwrapped, so an errors.Is against your own sentinel matches what you returned. The Redirect it receives carries the hop number, the 3xx's status and its headers, the URL that produced it, the resolved target, the method the next hop will use, and whether the hop crosses an origin or downgrades the scheme. The headers matter more than they look: a Set-Cookie or a routing header emitted on hop two is invisible to anyone who only sees the final response, and the two origin flags are there so a decision is made on a parsed comparison rather than on strings.Contains(To, "example.com"), which also passes for https://example.com.attacker.test. The hop is deliberately read-only — a callback able to rewrite the target would sit upstream of the origin scrubbing, which is exactly what keeps Authorization from following a redirect off-origin. It is not called for a 3xx with no Location, because there is no hop to veto, nor for the hop that would exceed the cap, because letting a veto answer that would quietly turn a "too many redirects" error into a success. Leaving the field nil changes nothing.
Redirect.GetHeader / Redirect.GetHeaders: the 3xx headers a redirect callback receives are lowercase-keyed, like every other header map in the library, so indexing the map with the spelling people actually write (Headers["Set-Cookie"]) returned nothing at all. These two read it case-insensitively, matching Response.GetHeader / Response.GetHeaders; use the plural for Set-Cookie, which legitimately repeats.
Request.GetBody re-opens a body that has to go out twice: needed only when the body is a genuine stream. For *bytes.Reader, *bytes.Buffer and *strings.Reader one is derived automatically at no cost, since the bytes are already in memory. It returns io.Reader rather than net/http's io.ReadCloser on purpose: the value is handed to http.NewRequestWithContext, and it is that function's type switch on the concrete reader type that sets Content-Length. An io.NopCloser wrapper hides the type, the length comes out unknown, and the request goes out chunked — so matching the standard library's signature exactly would have made a replayed hop use a different framing from the hop before it, which is a wire difference no browser produces.
HTTP/2 header compression did not match Chrome, and the gap widened with every request on a connection: the header list going out was correct, so nothing that inspects headers could see any of this. The HPACK instructions carrying that list were not Chrome's, in four separate ways, and three of them fired on the very first request.
The largest concerned cookies. They were crumbled correctly, one field per cookie-pair as a browser does, and then emitted in the never-indexed representation, which tells every intermediary the value is sensitive and must never be added to a compression table. No browser emits that. Chrome indexes cookie crumbs like any other header, which is why a browser sends a large cookie jar in full exactly once per connection and then refers back to it with a single byte per crumb for the rest of the connection's life. Because a never-indexed field is never stored, the entire jar was instead re-transmitted in full on every single request. Measured against a session carrying a bot-management-shaped jar, the request header block stayed near 880 bytes on every request where a browser settles at about 35, so roughly a 25x difference that grew with the size of the jar and persisted for as long as the connection did. Over a couple of thousand requests that is well over a megabyte of header traffic no browser would ever produce.
The authority pseudo-header had the same problem for the same reason: never stored, so re-sent as a full literal on every request instead of being referenced in one byte from the second request onward. The path and method pseudo-headers referenced the wrong entry of the static table when their value was not one of the two the table happens to contain, which put a different byte on the wire for any path other than / and for any method other than GET or POST.
All four now match a real browser byte for byte, verified against captures of the browser itself rather than against a specification. A session that reuses a connection now settles into the same small, fully-referenced header block a browser produces, and stays there: measured over sixty consecutive requests it holds flat with no growth or churn.
Two consequences worth knowing. Anyone who was recording the exact bytes of an outgoing header block will see them change. And header compression state is now shared across requests on a connection the way a browser shares it, which is what makes the small blocks possible; if a profile genuinely needs the old behaviour for a non-browser client, the never-indexed list is still configurable per profile, it simply no longer defaults to being populated.
A 307 or 308 through the root session silently sent no body at all: transport.Request carries a body in two fields, Body []byte and BodyReader io.Reader, and Session.Do populates only the second, because the public Request.Body is an io.Reader. The redirect path copied only the first. So the code said "307/308 preserve body" and did the opposite: the next hop went out empty, with the caller's Content-Type still on it, because the method is unchanged on those two codes and nothing strips it. There was no error, no short write and no truncation warning — the hop simply arrived with nothing in it and usually came back 200, which is why this survived so long. A POST that a payment or checkout endpoint answers with a 307 was the common way to meet it. The redirect path now re-opens the body from GetBody, carries GetBody onto the hop so a second 307 in the same chain also works, and refuses the hop with ErrBodyNotReplayable when the body is a one-shot stream with no way to re-open it, handing back the 3xx alongside the error so the caller can read its Location and drive the rest themselves. Sending a request the caller believes carries a body, without the body, is not a thing to do quietly. client.Client never had this bug — it buffers the body up front.
A retried request re-sent an exhausted body: the same root cause on the other path. The retry loop re-sends the same request object, so an attempt after the first streamed from a reader the previous attempt had already drained, and the server got the declared Content-Length with nothing behind it. The body is now re-opened for each attempt. Where a redirect refuses to proceed, a retry simply does not happen: the caller already has a response or a real error in hand, so a body that cannot be replayed disables the retry rather than failing the request.
A stale pooled HTTP/1.1 connection turned any request with a body into an empty one: the transport takes an idle connection from the pool, writes the request, and on failure closes it and retries on a fresh one. The retry reused the same *http.Request, whose body the failed attempt had already streamed out, so it went out with the Content-Length it had computed and no bytes behind it — the server then waited for a body that never arrived, and neither end reported anything wrong. Reachable by any POST that happened to draw a connection the origin had closed, and the reason the 307 fix above did not work on its own: the very next hop usually draws exactly such a connection. The body is now re-opened before the retry, and a body that cannot be re-opened surfaces the connection error rather than sending a corrupt request.
A redirect hop dropped almost every per-request option: the follow-up request was built from four fields — method, URL, headers and header order — so TLSOnly, DisableConditionalCache, DisableClientHints, DisableHighEntropyClientHints and the per-request Timeout were silently discarded after the first hop, and a per-request FollowRedirects: &true against a session that defaults to not following stopped the chain after one hop. A request that opted out of client hints got the opt-out on hop zero and the hints back on hop one, which is a fingerprint that changes mid-chain. All of them now ride along. The Timeout is a clamp rather than an extension: one overall deadline for the whole chain is still established at the first hop, and context.WithTimeout keeps the earlier of the two.
Exceeding the redirect cap threw the last response away: it returned a bare errors.New("too many redirects"), so a caller could neither match it with errors.Is nor see where the chain had got to. It is now ErrTooManyRedirects, and the session hands back the response carrying the last Location alongside it, the way net/http does when CheckRedirect fails. Nothing is leaked by ignoring it: the body is already buffered, so closing it is a no-op. The client package adopts the same sentinel but not the response, because at the point it detects the cap it has not built one yet.
Intermediate 3xx responses were never closed on the way through a chain. Harmless today, since the transport buffers each body before returning and hands back a no-op closer, and it stops being harmless the moment response streaming reaches that path.
A data race between concurrent requests sharing a pooled connection: the pool records a use count on every connection it hands out, writing it under the connection's mutex, but several callers read that counter back with no lock. On HTTP/2 and HTTP/3 one connection carries many requests at once, so two of them would collide, one inside the guarded write and the other reading bare. All reads now go through an accessor that takes the same mutex. Only the reported connect-time breakdown was ever at stake, never whether a request is sent or what it contains, but it is a real race and it failed builds run with the race detector.
Nothing published for this version
Nothing published for this version
First published release since 1.6.8 on PyPI, npm and NuGet. On the Go module proxy it supersedes v1.6.9, which is retracted: that tag was pushed from
First published release since 1.6.8 on PyPI, npm and NuGet. On the Go module
proxy it supersedes v1.6.9, which is retracted: that tag was pushed from a
pre-fix commit and proxy.golang.org has it permanently pinned there, serving a
build where TLS verification fails open on HTTP/3.
Highlights
Chrome 151 across Windows, Linux, macOS and Android, with chrome-latest*
repointed. Verified byte for byte against real captures over both TCP and
QUIC. The iOS profile ships as provisional and chrome-latest-ios stays on the
confirmed 150 build.
Certificate verification callbacks, so certificate pinning is possible
(#85). Supplied TLS configuration used to be accepted and then ignored;
callbacks now run on all three protocols, including HTTP/3, where they
previously failed open.
Saving a session no longer quietly weakens its certificate checks. A session
saved with verification configured now refuses a plain restore rather than
coming back with the permissive half only.
Long downloads are no longer cut off after roughly two minutes (#83).
Response bodies could be silently corrupted under concurrency. Fixed.
The HTTP/3 handshake now matches a real Chrome capture parameter for
parameter, and no longer opens a throwaway TCP connection before the request.
Headers the preset reserves no slot for keep a stable order instead of a
randomised one, on all three protocols.
The documented local-proxy pattern applied no fingerprint at all, in every
language. Requesting an https:// URL through the proxy tunnels past it. All
guides, readmes and examples are corrected, and .NET gains
LocalProxy.CreateClient().
Full notes in CHANGELOG.md.
Response TLS details: a response can now carry the negotiated TLS information for the connection that produced it (protocol version, cipher, negotiated application protocol, and the leaf certificate's subject, issuer, names, validity and fingerprint). It is off by default and requested per request, because parsing certificate fields is not free and most callers do not want to pay for it on every response.
Preset constants for the newest profiles in every binding: the typed preset tables in the Python, Node and .NET packages had fallen two releases behind, so neither Chrome 150 nor Chrome 151 nor the per-OS Firefox profiles were reachable from a named constant in any of them. All are now present.
Chrome 151: asking for the latest Chrome now gives you Chrome 151 on Windows, Linux, macOS and Android, and the matching chrome-latest* profiles point at it. Everything below the header layer is unchanged from Chrome 150, including the post-quantum signature algorithms, and that was confirmed against real Chrome 151 captures over both TCP and QUIC. Worth noting for anyone building profiles by hand: the browser's brand list is not a simple version bump between 150 and 151. The separator characters, the filler brand's version and the order the three brands appear in all changed at once, so the value has to be transcribed from a capture rather than derived. A Chrome 151 iOS profile ships too, but iOS spells its version out in full and that build number cannot be derived, so it is provisional and chrome-latest-ios deliberately still resolves to the confirmed 150 profile until a capture lands.
LocalProxy.CreateClient() and LocalProxy.CreateFingerprintHandler() on .NET: building a client from the proxy now actually fingerprints https:// requests, which pointing a plain proxy handler at the proxy URL never did (see below). CreateClient() takes an optional handler of your own, so cookies, credentials and decompression settings survive, and CreateFingerprintHandler() covers the case where something else owns the client, such as a factory or a library that takes a handler.
Certificate verification callbacks (#85): you can now supply your own certificate checks, either individually or by handing over a standard TLS config, and they work the same way the standard library's do: one callback receives the raw certificates and any chains that were built, the other receives the completed connection state, and returning an error from either aborts the handshake. This is what you want for certificate pinning. Only the verification parts of a supplied TLS config are read; the parts that would reshape the handshake are deliberately ignored, because honouring them would silently change how the client looks on the wire, which is the one thing this library exists to keep stable.
Per-OS Firefox profiles: firefox-133 and firefox-148 now ship -windows, -linux and -macos variants, with matching firefox-latest-* aliases, so you can pin Firefox-on-Windows from a Linux host without hand-overriding the User-Agent. The plain firefox-133 / firefox-148 names still follow the host OS, so nothing changes for existing code. Unlike Chrome, the variants differ in the User-Agent only: Firefox does not vary the rest of its fingerprint by operating system, so the TLS bytes, HTTP/2 settings and header order are identical across all three, and that is locked by a test.
Request.HeaderOrder sets the header order for a single request, without touching the session: SetHeaderOrder is session-wide state behind a mutex, so pinning an order for one request meant set, send, restore, and every concurrent request on that session had to be serialized around the window where the override was live, or it went out under someone else's order. That made the documented ordering control unusable on a session doing parallel work, which is exactly where it was needed: adding a single header a browser never sends (an API token, a signature) leaves it in the sorted tail unless you can say where it belongs. The Request types in the root, client, and transport packages now carry a HeaderOrder field that applies to that request alone and overrides whatever is installed session-wide. It is read from the request rather than from shared state, so no lock is taken and concurrent requests can each carry a different order. The semantics match SetHeaderOrder exactly, the list is a prefix, the preset's position table still covers every header you leave out, and a stable sorted tail catches the rest, so naming one header costs you nothing for the others, and naming all of them gives you the exact wire order. Names are case-insensitive. The order carries across followed redirects, matching the way the redirect path already replays your headers onto each hop: without that, a header you slotted explicitly would still be sent on the next hop but re-placed by the preset table, leaving the header set and its order disagreeing mid-chain. Leaving the field unset changes nothing.
The Go client.Client API now sends the same TLS handshake as the session API: the client hello is assembled in two places internally, and only one of them applied a profile's signature-algorithm override. A caller using the client package with a Chrome 150 or 151 profile therefore sent that browser's headers and User-Agent alongside a handshake missing its post-quantum signature entries, which is an internally inconsistent fingerprint and exactly the kind of contradiction this library exists to remove. Both paths now agree. This changes the handshake those callers send: it moves to the correct value, confirmed against a real capture, but anyone recording or pinning the previous hash will see it change. Profiles that carry no signature-algorithm override are unaffected, byte for byte.
A partial SetHeaderOrder now extends the preset's order rather than replacing it: the list you passed used to replace the preset's ordering table wholesale, which meant every preset header you did not name joined the randomised remainder described above, so reaching for the documented ordering control made your fingerprint worse than leaving it alone. Your list is now a prefix override: the headers you name come first in the order you gave them, then the preset's own table covers everything you did not name, then anything still unplaced follows sorted by name. Passing a complete order list behaves exactly as before, and passing nil or an empty list still resets to the preset default.
Response.Location() resolves the redirect target the way net/http does: the standard library's http.Response has a Location() method that parses the Location header into a *url.URL and resolves it against the request URL, so a relative /login comes back as the full https://host/login. httpcloak responses only exposed the raw header string, which left anyone inspecting a 3xx with redirects disabled to re-implement that resolution by hand. The Response types in the root, client, and transport packages now carry the same method: it parses the header, resolves a relative target against the URL that produced the response (FinalURL), and returns the package's ErrNoLocation sentinel when no Location header is present, matching net/http semantics exactly, which is locked by a parity test that runs the same cases through both implementations.
The local proxy silently truncated every compressed response: it decompresses a body before handing it back, and correctly dropped the header saying the body was compressed, but kept the origin's byte count, which counts the compressed bytes. The caller therefore stopped reading that many bytes into a larger body and treated the response as finished. A 68 KB page compressed to a few hundred bytes arrived as a few hundred bytes of plaintext, with no short read, no decode failure and no error anywhere. Anyone pointing the local proxy at a compressed origin that declares a length has been getting quietly wrong data; it went unnoticed because the endpoints usually used to exercise this path serve either uncompressed or chunked responses. Both headers are now dropped together and the response is delimited by connection close.
The documented local-proxy pattern applied no fingerprint at all, in every language: the local proxy fingerprints a request only when it arrives as a proxy-style plain HTTP request. Ask any mainstream client for an https:// URL through a proxy and it opens a tunnel instead, then performs its own TLS handshake straight through to the destination, so the destination sees the calling client's handshake and the proxy is left relaying bytes it cannot read. Nothing errors and a real response comes back, which is why this went unnoticed: the quick-start snippets in the guides, both binding readmes, the .NET example and its test all taught exactly that shape, under comments stating the fingerprint was being applied. Measured against a fingerprint reflector, the tunnelled requests were byte-identical to the same client with no proxy configured at all. The escape hatch already existed and was already described in the reference section: request the target as plain HTTP and add the scheme-upgrade header, which keeps the handshake on the proxy side. Every snippet across the guides, both readmes and both examples now uses it, with the measurements alongside so the difference is checkable rather than asserted. The same applies to the per-request session header, which on a tunnelled request travels inside the tunnel where the proxy cannot read it, so session routing was silently doing nothing too. .NET gets a code-level fix as well, see above; for the other languages this is a documentation correction, and anyone who built on the old snippets should assume those requests were never fingerprinted.
Non-browser profiles no longer advertise a browser-specific HTTP/3 parameter: the switch deciding whether to send Chrome's browser-specific transport parameters was reading a field that actually selects parameter ordering, and that field defaults to the Chrome setting for any profile that does not override it. A Firefox profile forced onto HTTP/3 therefore sent a Firefox-shaped handshake carrying a Google-only parameter, which contradicts itself to anyone reading it. The decision now follows the profile's own client identity.
Supplied TLS configuration was accepted and then ignored (#85): the option to pass a TLS configuration already existed, so code that set verification callbacks compiled and looked correct, but nothing ever read it and the callbacks never ran. Anyone relying on them for certificate pinning was performing no extra verification at all while believing they were. They now run, and rejecting a certificate genuinely fails the request.
Long downloads no longer get cut off after about two minutes (#83): for HTTP/2 a request is considered finished as soon as the response headers arrive, but the body can keep streaming for minutes afterwards. The connection pool was only tracking the first part, so a large or slow download looked completely idle and the pool would close the connection out from under the reader, roughly two minutes in. Connections are now held for as long as the body is actually being read, and a connection is never closed while a response is still streaming on it, however old it looks. A body that is abandoned without being closed is still reclaimed after a long idle period, so this cannot leak connections.
Response bodies could be silently corrupted under concurrency: response bodies were read into buffers taken from a shared pool, and the buffer was handed to the caller while still belonging to that pool. On one path it was returned to the pool while the response still pointed into it, so a later request could overwrite a body that had already been handed out. There was no crash and no error, just wrong bytes. Bodies now always belong solely to the caller.
Two QUIC transport settings were being dropped, and two more were being sent that no browser sends: on HTTP/3 connections the configured datagram frame size and the browser-specific transport parameters were lost before the handshake was assembled, so the connection advertised an internal default and omitted two parameters a real browser always includes. Separately, the code built two parameters that a real Chrome does not send at all; they never actually reached the wire, because the same dropped-configuration bug discarded them too, so removing them is a correctness cleanup rather than a change anyone could observe. One of them was worse than a stray value: producing it required opening and timing a throwaway TCP connection to the destination before the HTTP/3 request, which is something a browser never does. That connection is gone. All four are corrected, and the HTTP/3 handshake now matches a real Chrome 151 capture parameter for parameter. This changes the HTTP/3 handshake for every Chrome profile, not just the newest one.
Headers the preset reserves no slot for now keep a stable order instead of a random one: any header outside the impersonated browser's ordering table, custom X- headers, API tokens, signature headers, fell through to Go's map iteration in both the HTTP/1.1 writer and the HTTP/2 header encoder, and that iteration is deliberately randomised. The same four custom headers produced eight distinct arrangements across eight requests on HTTP/1.1, and seven across eight on HTTP/2. A header order that changes on every request is itself a strong signal, because no browser produces one, and it landed on exactly the requests most likely to be carrying something worth checking. Every header is now named up front, so nothing reaches the randomised fallback, and HTTP/1.1, HTTP/2 and HTTP/3 all agree on the result.
Certificate verification could be skipped entirely on HTTP/3: the verification callbacks added in this release were never invoked on an HTTP/3 connection. That failed open rather than closed - a callback that is never consulted can never reject - so a caller pinning a certificate believed they were pinned while the HTTP/3 path accepted anything the system trust store accepted, and on a client that prefers HTTP/3 that is the connection the request actually used. Two separate causes, both fixed: the conversion into the QUIC handshake rebuilt the TLS configuration field by field and dropped both callbacks, and HTTP/3 builds its configuration once up front rather than per connection, so installing the callbacks afterwards never reached it. Both now work, and a rejection genuinely aborts the connection. A restricted set of trust anchors was being dropped the same way, with the same fail-open consequence, and is now honoured.
Saving and restoring a session no longer quietly weakens its certificate checks: verification callbacks and a restricted set of trust anchors are a function and an in-memory structure, so a saved session could never carry them. The flag that turns the built-in chain check off is a plain boolean and saved perfectly well. So a session configured as "skip the built-in check, I verify the certificate myself" came back as "skip the built-in check", with nothing in its place: weaker than pinning, and weaker than plain verification, with no error anywhere to say so. A saved session now records which of these were configured, and restoring one without them fails instead, naming exactly what is missing. This is a behaviour change for anyone restoring such a session: the plain load now returns an error where it used to return a working session. New loader variants take the callbacks and the trust anchors back so the session is restored with the posture it was saved with; supplying only some of them is refused for the same reason. Sessions saved without any of this load exactly as before, files written by older versions keep working, and files written now still load on the previous release. The bindings cannot install these callbacks in the first place, so nothing there is affected.
Cancelling a request on HTTP/1.1 now takes effect immediately: the HTTP/1.1 path never looked at the request context once the exchange started, so a caller who cancelled waited out the full response timeout and then got back an opaque network error that did not identify itself as a cancellation. Cancelling now interrupts the exchange, including during the response body, and reports the caller's own cancellation. Connections interrupted this way are dropped rather than reused, since their position in the response stream is unknown.
Proxy handshakes are bounded by the request context: negotiating a tunnel through an HTTP or SOCKS5 proxy performed unbounded reads and writes, so a proxy that accepted the connection and then went silent held the request until the operating system gave up, with no way for the caller to interrupt it.
Browser-specific request metadata is no longer added to profiles that do not send it: an API-shaped request had a set of browser navigation headers rewritten onto it to keep a browser coherent. Applied to a profile describing a non-browser client, that did not fix an inconsistency, it invented headers that client never sends. Profiles whose header set declares none of them are now left alone.
The .NET cancellation lifecycle no longer leaks: cancellation registrations were never disposed, so a long-lived cancellation source driving many requests accumulated one per request, and cancelling did not release the corresponding native callback slot. An already-cancelled token now short-circuits without issuing the request.
Chrome 151 across Windows, Linux, macOS and Android, with chrome-latest* following it. Per-OS Firefox profiles. Certificate verification callbacks for
Chrome 151 across Windows, Linux, macOS and Android, with chrome-latest*
following it. Per-OS Firefox profiles. Certificate verification callbacks for
pinning. Response.Location(). Per-request header order.
Fixes both halves of the long-download problem: connections are held for the
response body's lifetime in both connection pools, so a large or slow transfer
is no longer cut off around two minutes in. Response bodies could be silently
corrupted under concurrency; HTTP/1.1 ignored context cancellation; proxy
handshakes were unbounded; the local proxy silently truncated compressed
responses.
Two changes are visible on the wire. The Go client package now sends the same
handshake as the session API, which moves its fingerprint to the correct value
for profiles carrying a signature-algorithm override. A partial header order is
now a prefix that extends the profile's own table rather than replacing it.
See CHANGELOG.md for the full list.
Chrome 150 across desktop, iOS and Android, with post-quantum signatures where the real browser sends them: asking for the latest Chrome now gives you
chrome-latest* profile points at it. On the platforms that run Chromium's own stack (the three desktops and Android) the TLS handshake now advertises the post-quantum signature algorithms the current Chrome offers, so the signature list matches a real browser on the wire; iOS runs on the system stack and, exactly like the real thing, does not advertise them. Because the library keeps everything on the wire configurable, custom profiles get a new per-protocol control to add or drop the post-quantum signatures independently for the TCP and the QUIC handshakes, so you can opt in or out to match whatever you need. The desktop and iOS wire fingerprints were confirmed against real captures across the Python, Node and C# bindings; the Android one is derived from the shared desktop stack.Forced HTTP/3 no longer hangs when a QUIC path goes quiet after connecting: in HTTP/3-only mode there is no other protocol to fall back to, so a connection that finished its handshake but then stopped delivering the response (for example a network that quietly drops the larger response packets over IPv6 while keep-alives still flow) would leave the request waiting for the whole timeout. The request now bounds the wait for the first response, and if the path has stalled it drops that connection and retries once on a fresh one, preferring the other address family. Healthy connections and streaming bodies are untouched, so there is no cost on the normal path.
A pointed encrypted-hello config domain can no longer stall the whole request (#74): when a session was aimed at an ECH configuration domain that did not actually front the real target, the TLS handshake could sit blocked for the entire request budget. The encrypted-hello attempt is now given its own short deadline, and if it stalls the session retries once in the clear and remembers the host is incompatible so later requests skip it.
Streaming works against servers that only speak HTTP/1.1 (#75, #77): the streaming path had no HTTP/1.1 fallback, so a server that negotiated plain HTTP/1.1 broke streaming requests and then retried straight back into the same mismatch. Streaming now falls back to HTTP/1.1 cleanly.
The C# handler and the session produce the same fingerprint (#79): HttpCloakHandler routed its requests differently from Session, so the two could look like different clients on the wire. The handler now goes through the same path as the session.
The IPv6 TCP fingerprint is complete (#81): the outgoing SYN packet kept the operating system's default IPv6 hop limit instead of the value the impersonated browser's OS uses. It now carries the right one.
The advertised TCP window size matches the fingerprint (#73): the window value in the SYN now lines up with the rest of the fingerprint. The window scale factor stays fixed by the host operating system's socket interface, which is a platform limitation rather than something the library can set.
A broad networking robustness pass: DNS now honours the real record TTLs, caches negative answers, and collapses duplicate concurrent lookups for the same host; proxy dialling tries every resolved address rather than the first, and MASQUE tunnels are keyed per host; every request path is now bounded by the configured timeout from start to finish; and the native library layer was hardened against crashes and memory issues under concurrent use.
Python 3.14 free-threaded (no-GIL) builds are supported (#80).
Nothing published for this version
Nothing published for this version
chore(release): bump version to 1.6.8-beta.1, cut CHANGELOG [1.6.8-be…
chore(release): bump version to 1.6.8-beta.1, cut CHANGELOG [1.6.8-be…
Accept-CH for things like sec-ch-ua-full-version-list, sec-ch-ua-platform-version, sec-ch-ua-arch), the session started sending them on the next request. The problem was that those detailed hints were built from a stale hardcoded table that had fallen behind: the full version list reported an older browser version with a mismatched brand token and brand order, while the User-Agent and the always-on sec-ch-ua reported the current version. A server that reads both could see the contradiction and tell the client apart from a real browser. On Linux the platform version was also a made-up value where a real browser sends an empty one. All of these now come straight from the chosen preset, so the detailed hints always line up with sec-ch-ua and the User-Agent: same version, same brand names, same order, same GREASE brand token, with Linux sending the empty platform version like the real browser does. Adding a future browser version is now a one-place change. Two more fixes ride along: the detailed hints are now sent on streaming requests too (the streaming path used to skip them, so a host that saw both a normal and a streaming request from the same session got the hints on one and not the other), and overriding any sec-ch-* header per request now reliably wins instead of sometimes losing to the injected value. Finally there are real controls to stop the library adding these headers at all: a session option (and per-request flag) to drop every sec-ch-* header except the ones you set yourself, and a softer one that keeps the always-on sec-ch-ua / sec-ch-ua-mobile / sec-ch-ua-platform trio but suppresses only the detailed hints, both with runtime toggles, wired through Python, Node, and C#. Locked by a coherence test across every browser preset plus end-to-end checks for the opt-outs, the streaming parity, and the override behaviour.Redirects now send a browser-like Referer on each hop (#70): when a session followed a redirect it carried whatever Referer was already on the request
strict-origin-when-cross-origin policy on every hop: a same-origin redirect sends the full previous URL (with the fragment and any credentials stripped and a default port dropped, exactly as Chrome serializes it), a cross-origin redirect on the same secure scheme sends the previous origin only (for example https://example.com/), and an https to http downgrade sends no Referer at all. Locked by a table-driven policy test plus a local end-to-end check against a redirecting server.Session.Do / DoWithBody silently dropped the per-request Request.Timeout, so it never reached the transport; (2) the session-level timeout (Session(timeout=...) / WithSessionTimeout) was stored but never wired to the transport, so it stayed on the 30s default no matter what you set; (3) protocol fallback (auto mode trying HTTP/2 then HTTP/1.1) re-derived a fresh timeout budget per attempt, so the budgets added up and a 4s timeout could ride to 8 to 12 seconds. Now the per-request timeout reaches the transport, the session timeout acts as the default deadline, and the whole request including any fallback is bounded by one overall deadline. Verified with a stalling-proxy harness: a request that used to ride to 30s now aborts at exactly its configured timeout, and the auto HTTP/2 to HTTP/1.1 fallback no longer doubles it. If you were passing a per-request timeout as a workaround it now does what you expect, and the session-level timeout works on its own too.illegal parameter rejections or handshake timeouts, all at once, and only a process restart cleared it. Cause: the HTTP/3 transport cached the host's ECH (Encrypted Client Hello) config and pinned it for the lifetime of the session with no expiry and no way to drop it. When a CDN rotates its ECH keys (which happens on a schedule, so independent servers all break at the same minute), the pinned config is stale, the server rejects every handshake built from it, and nothing refetched a fresh one. Now the cached ECH config carries a short TTL so a long-lived session refetches periodically, and it is dropped immediately when a handshake is rejected in a way that looks like a stale config, so the session self-heals on the next request instead of needing a restart. The DNS-level ECH cache also stops serving an indefinitely-expired config when a refresh lookup fails (it now falls back to no-ECH past a short grace window, which still connects). If you want to sidestep ECH entirely, disable_ech / disableEch on the session still does that.cookie: a=1; b=2; c=3 line on H2/H3. They split it into one cookie field per pair (cookie: a=1, cookie: b=2, cookie: c=3) for header-compression efficiency, which RFC 9113 section 8.2.3 allows. httpcloak's Chrome presets were sending a single coalesced field, on the assumption that Chrome coalesces. That assumption was wrong: I had reasoned it from the wrong layer and locked it in without checking against a real capture, so any server that counts the decoded cookie fields could tell the difference. Chrome and Firefox presets now crumble the cookie into per-pair fields on both H2 and H3, matching the browser exactly (split on each ;, drop one following space, kept contiguous in the cookie slot and marked never-indexed). Safari stays a single field, which is correct for WebKit. The split follows the jar and any caller-supplied Cookie header alike. Thanks to the folks who reported this and pointed at the RFC. Verified on the wire for Chrome, Firefox, and Safari across both protocols.request_async ran multipart through body_bytes.decode("latin-1") (lossy on the JSON-encoding side) and raw bytes through data.decode("utf-8") (raised on any non-UTF-8 byte); Node's post() / request() async did body.toString("utf8") on any Buffer body. Both bindings now base64-encode binary payloads and set body_encoding="base64" on the request config so the cgo boundary preserves every byte. Sync paths and text bodies are unchanged. cgo's post_async entry point gained a matching body_encoding field on RequestOptions so the binary safety is end-to-end. Verified with a 1024-byte payload covering 0x00..0xFF round-tripping sha256-clean through httpbin.setSessionIdentifier() no longer crashes on first call: the method was declared in JS and TypeScript but the underlying httpcloak_session_set_identifier entry point was missing from the koffi lib table; any user calling session.setSessionIdentifier("foo") got an immediate runtime error. Registered.LocalProxyStats TypeScript interface no longer fabricates fields: the .d.ts declared totalRequests / activeConnections / failedRequests / bytesSent / bytesReceived as camelCase fields. The C-API actually emits snake_case running / port / active_conns / total_requests / preset / max_connections / registered_sessions, and failedRequests / bytesSent / bytesReceived aren't in the wire format at all. The interface now mirrors what Node code actually receives.availablePresets() TypeScript return type fixed: declared string[] but the runtime returns Record<string, { protocols: string[] }>. The type now matches reality.disable_ech / disableEch ctor flag silently dropped in Python and .NET: the clib SessionConfig.DisableECH field has accepted this since ECH shipped, but the Python ctor never passed it through and the .NET ctor didn't even have the parameter. Both bindings now expose disable_ech / disableEch ctor kwargs that wire to the JSON config.StreamResponse.cookies exposed only 2 of 9 cookie fields: get_stream / post_stream / request_stream all built their Cookie objects with just name and value, silently dropping domain, path, expires, max_age, secure, http_only, same_site. Stream cookies now carry the same metadata as non-stream cookies.index.mjs was missing 5 named exports: PresetPool, loadPreset, loadPresetFromJSON, unregisterPreset, describePreset were exported from the CJS index.js but not re-exported from the ESM entry point. ESM consumers got undefined. All five are now re-exported.httpcloak package missing Cookie, RedirectInfo, StreamResponse, FileValue from __init__.py: these were reachable only via httpcloak.client.X, blocking idiomatic type-hint usage and isinstance checks. Added.contentType parameter: the binding chapter documented string? contentType = null on every PostFast / RequestFast / PutFast / PatchFast variant; the actual code never had it. Any user who copied the doc signature got a compile error. The doc is now honest (Content-Type goes via the headers dictionary).withoutConditionalCache: the code had the parameter, the prose example used it, but the canonical signature block in the binding chapter never listed it. Fixed.ClearCache was Go-only: the table said "not exposed × 3"; reality is clear_cache() / clearCache() / ClearCache() are exposed in every binding (have been since the conditional-cache work landed). Corrected.Chrome 149 preset (desktop): chrome-149 plus the chrome-149-windows / -linux / -macos variants, and chrome-latest now tracks 149. Chrome 149's TLS and HTTP/2 fingerprint is identical to 148, verified on the wire (same JA4 t13d1516h2_8daaf6152771_d8a2da3f94cd and the same Akamai H2 fingerprint), so this is a header refresh rather than a new wire shape: the User-Agent moves to 149.0.0.0 and the sec-ch-ua brand list rotates to "Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24" (Chrome reorders the brands and rolls the GREASE brand token each version, so it is not a plain version bump). The preset inherits everything else from chrome-148, and the new constants are exposed in Python, Node, and .NET. Mobile (Android / iOS) stays on 148 until a real Chrome 149 mobile capture is confirmed.
Top-level Go convenience wrappers: httpcloak.NewManager() (plus the httpcloak.Manager alias for session.Manager), httpcloak.ValidateSessionFile(path), and httpcloak.SetKeyLogWriter(w) are re-exported at the package root, so Go callers get session-pool management, save-file validation, and TLS key-log wiring without reaching into the session subpackage.
.NET SessionCacheBackend managed wrapper: distributed TLS session cache is finally reachable from .NET. Implement ISessionCache (six methods: Get/Put/Delete + GetEch/PutEch + OnError, with default implementations for the ECH pair and OnError), pass to new SessionCacheBackend(impl), call Register(). The wrapper pins the six callback delegates as instance fields so the GC can't collect them while Go still holds function pointers; trailing-buffer pattern frees the last-returned C string on the next callback so memory doesn't leak. IDisposable + finalizer cleanup, single-active-backend semantics with auto-unregister-on-replace, HttpCloakCache.ConfigureSessionCache(impl) / ClearSessionCache() one-liner shorthands matching Python's idiom. End-to-end verified: TLS session ticket round-trips through an in-mem dict, 0 callbacks fire after Unregister.
.NET binary / Stream / multipart async overloads: PostAsync(byte[]), PostAsync(Stream), PutAsync(byte[]), PutAsync(Stream), PatchAsync(byte[]), PatchAsync(Stream), PostMultipartAsync(...), RequestBinaryAsync(method, byte[]), RequestStreamAsync(method, Stream), WarmupAsync(url, timeoutMs). The .NET binding had string-only async methods even though the sync surface had binary overloads; anyone needing the HttpClient.PostAsync(byte[]) idiom had to drop to sync or convert binary to UTF-8 (lossy). All binary overloads route through RequestBinaryAsync which base64-encodes and sets body_encoding=base64 so non-UTF-8 bytes survive the cgo boundary.
AbortSignal / asyncio.wait_for cancellation actually cancels the Go-side goroutine: httpcloak_cancel_request has existed in the C-API since 2026-01 but neither Python nor Node wired it. Python's _AsyncCallbackManager.register_request installs a future done-callback that calls cancel_request(cid) then unregister_callback(cid) on cancellation; Node's AsyncCallbackManager.registerRequest accepts an optional AbortSignal and runs the same sequence. The order matters: cancel unblocks the goroutine via ctx.Done, then unregister removes the entry from Go's asyncCallbacks map so the goroutine's final invokeCallback finds !exists and returns silently. Without the unregister step Node would crash with Error::ThrowAsJavaScriptException napi_throw during teardown when the late callback tried to throw into a torn-down env. Verified: asyncio.wait_for(timeout=1) and AbortController.abort() both cancel httpbin.org/delay/5 in 1.00s, no teardown crash, session usable for a fresh GET after. .NET already had CancellationToken wired.
LocalProxy session enumeration (all bindings): LocalProxy.list_sessions() / listSessions() / ListSessions() returns the IDs currently registered on the proxy; LocalProxy.has_session(id) / hasSession(id) / HasSession(id) is a cheap existence check that skips the JSON marshal. Two new C-API exports back it. Useful for operational dashboards, stale-registration GC in long-running processes, and confirming sessions actually reached the proxy.
Session observability quartet (all bindings): Session.stats() / Stats(), idle_time() / idleTime() / IdleTime(), is_active() / isActive() / IsActive(), touch() / Touch(). Four new C-API exports return a JSON snapshot (id, preset, created_at, last_used, request_count, active, cookie_count, cache_entry_count, age_ns, idle_time_ns, transport_stats), the idle-time in nanoseconds, an active flag, and a touch primitive that resets the idle timer without issuing a request. Long-running scrapers can finally scrape per-session metrics into Prometheus / Datadog from any binding. .NET ships a typed SessionStats class with CreatedAt / LastUsed (DateTimeOffset) and Age / IdleTimeSpan (TimeSpan) helper properties.
Chunked upload (Node uploadStream, .NET UploadStream): Python had _streaming_upload since the 5-entry cgo upload state machine landed; Node and .NET had no managed wrapper, so multi-GB file uploads from those bindings had to either materialise in memory or proxy through LocalProxy. Now Session.uploadStream(method, url, chunks, options) accepts any iterable / async-iterable yielding Buffer / Uint8Array / string (Node) or IEnumerable<byte[]> (.NET); chunks flow straight through the Go-side io.Pipe() with no base64 envelope. Cancellation on exception calls upload_cancel. Verified with 4 × 1 KiB chunks covering 0x00..0xFF: status 200, sha256 round-trip match on both bindings.
Preset constants refreshed across all bindings: Preset.CHROME_LATEST / CHROME_148 / CHROME_147 families (plus Windows/Linux/macOS/iOS/Android platform variants), FIREFOX_LATEST / FIREFOX_148, SAFARI_LATEST / SAFARI_LATEST_IOS. 73 named constants per binding (including backwards-compat aliases), all verified resolving to real entries in the runtime registry. Preset.all() returns the same set. Backwards-compat aliases (IOS_CHROME_148, ANDROID_CHROME_LATEST, IOS_SAFARI_LATEST, etc.) keep older naming working.
Explicit disable_http3 / disableHttp3 ctor flag (all bindings): was already reachable indirectly via httpVersion="h2" (which implies WithDisableHTTP3 on the Go side) but the explicit flag is cleaner for callers who just want "no H3" without committing to a specific lower version. cgo's SessionConfig.DisableHTTP3 is wired; ctor params added on Python (disable_http3=False), Node (disableHttp3: false), .NET (bool disableHttp3 = false). Verified all three force protocol=h2 on a fresh request when set.
.NET StreamResponse property surface symmetry: StreamResponse now exposes Elapsed (TimeSpan), Encoding (charset parsed from Content-Type), and History (always empty for streams since the stream layer doesn't follow redirects, but the property exists for symmetry with Response and FastResponse so callers can iterate without a null check).
Node availablePresets() / describePreset(name) properly typed in .d.ts: availablePresets() return type now declares Record<string, { protocols: string[] }> matching the runtime shape; describePreset(name): string declaration added so TypeScript callers no longer need (httpcloak as any).describePreset(...) cast.
Node binding chapter "Other exports" section rewritten: was 6 names, now 22 with one-line descriptions and cross-links: PresetPool, Preset constants, Cookie, RedirectInfo, describePreset, loadPreset, loadPresetFromJSON, unregisterPreset, setEchDnsServers, getEchDnsServers, availablePresets, version, plus the module-level get/post/... convenience funcs.
Conditional-cache control surface (all bindings): the session has always behaved like a real browser by replaying ETag and Last-Modified as If-None-Match / If-Modified-Since on the next request to the same URL. That's the right default for fingerprint authenticity, but callers had no way to opt out short of recreating the session. Three new controls land together:
WithoutConditionalCache() SessionOption (Go), without_conditional_cache=True (Python), withoutConditionalCache: true (Node.js), withoutConditionalCache: true (.NET): disables validator injection and storage for the lifetime of the session.SetConditionalCacheEnabled(bool) / ConditionalCacheEnabled() (Go), set_conditional_cache(bool) / get_conditional_cache() (Python), setConditionalCache(bool) / getConditionalCache() (Node.js), SetConditionalCache(bool) / GetConditionalCache() (.NET). Toggle the same state mid-session; existing entries are preserved when paused.disable_conditional_cache=True (Python) / disableConditionalCache: true (Node.js) / disableConditionalCache: true (.NET) / Request.DisableConditionalCache (Go). Wired on every request method in every binding: get / post / put / delete / patch / head / options / request / getSync / postSync / requestSync / getStream / postStream / requestStream and the JSON / Async / sibling variants.ClearCache() (clear_cache() / clearCache() / ClearCache()) is now exposed in every binding (was Go-only).Redirect runtime control (all bindings): Session.SetFollowRedirects(bool) / FollowRedirects() and SetMaxRedirects(int) / MaxRedirects() (Go, with snake_case Python and camelCase Node / PascalCase .NET equivalents). The per-request override allow_redirects (Python) / allowRedirects (Node.js, .NET) / Request.FollowRedirects *bool (Go) wins over the session default for a single call. Available on every request method in every binding (same coverage as disableConditionalCache above). Closes the gap that previously required recreating a session to flip redirect-following.
SetHeaderOrder / GetHeaderOrder now use source-gen JSON: previously called reflection-based JsonSerializer.Serialize<string[]>(order) which breaks NativeAOT (trim warnings, runtime failures). Switched to the JsonContext.Default.StringArray source-gen path that the rest of the binding uses.transport.Request and httpcloak.Request gain FollowRedirects *bool and DisableConditionalCache bool fields. The session-layer requestWithRedirects honours both before falling back to s.Config.FollowRedirects and the session's conditionalCacheEnabled flag.httpcloak_session_clear_cache, httpcloak_session_set_conditional_cache, httpcloak_session_get_conditional_cache, httpcloak_session_set_follow_redirects, httpcloak_session_get_follow_redirects, httpcloak_session_set_max_redirects, httpcloak_session_get_max_redirects. The clib RequestOptions and RequestConfig JSON shapes accept follow_redirects and disable_conditional_cache.protocol.SessionConfig gains WithoutConditionalCache bool; protocol.RequestOptions gains DisableConditionalCache bool and the pre-existing FollowRedirects *bool field is now actually consulted.Nothing published for this version
chrome-148 desktop + android presets
chrome-148 desktop + android presets
Adds chrome-148-windows / chrome-148-linux / chrome-148-macos /
chrome-148-android. Wire-level diff vs chrome-147 is just two
header values (User-Agent version bump + sec-ch-ua brand list
rotation). TLS extension shuffle continues per-handshake the
same way utls already produces; JA4 stays
t13d1516h2_8daaf6152771_d8a2da3f94cd. chrome-latest aliases
(and platform-specific chrome-latest-* + android-chrome-latest)
now resolve to 148. iOS already at 148 from v1.6.5.
WithoutCookieJar() across all 4 bindings
New SessionOption that disables the internal cookie jar
entirely — Set-Cookie headers from responses are not stored,
the jar is not consulted to inject Cookie: headers on
subsequent requests. Caller-provided Cookie: headers always
pass through. Useful when an application maintains its own
cookie store (database, shared cache across sessions) and
wants the lib to be byte-transparent about cookies.
Guards both Request and RequestStream paths in the session
layer. Design originally proposed in andreacanes/httpcloak
(based on gkopp13's patch); implementation expanded to cover
Set-Cookie storage paths in addition to the inject-on-request
path.
WithLocalAddrIP(net.IP) ergonomic alias
Drop-in net.IP-typed sibling for WithLocalAddress(string).
Lets callers who already hold a parsed IP skip the String()
round-trip. Same internal storage, nil net.IP is a no-op so
conditional option chains don't accidentally clobber a
previously-set address.
Other notable
chrome-148-windows, chrome-148-linux, chrome-148-macos, chrome-148-android plus their Chrome148Windows() / Chrome148Linux() / Chrome148macOS() / Chrome148() / AndroidChrome148() Go constructors. Wire-level diff vs chrome-147 is just two header values: User-Agent version bump (Chrome/147 → Chrome/148) and sec-ch-ua brand list rotation (Chromium moved to first position, GREASE brand "Not.A/Brand";v="8" → "Not/A)Brand";v="99"). TLS extension shuffle continues per-handshake the same way utls already produces for chrome-147; JA4 stays t13d1516h2_8daaf6152771_d8a2da3f94cd, Akamai HTTP/2 fingerprint stays unchanged. chrome-latest / chrome-latest-windows / chrome-latest-linux / chrome-latest-macos / chrome-latest-android aliases now resolve to 148. chrome-148-ios was already shipped in v1.6.5.WithoutCookieJar() SessionOption (all bindings) — Disables the session's internal cookie jar entirely. When set, Set-Cookie headers from responses are NOT stored and the jar is NOT consulted to inject Cookie: headers on subsequent requests; cookie management is left fully to the caller via per-request headers. Useful when an application maintains its own cookie store (database, shared cache across sessions) and wants the lib to be byte-transparent about cookies. Caller-provided Cookie: headers always pass through regardless of this option. Available across Go (httpcloak.WithoutCookieJar()), Python (without_cookie_jar=True), Node.js (withoutCookieJar: true), and .NET (withoutCookieJar: true). Guards both Request and RequestStream paths in the session layer.WithLocalAddrIP(net.IP) ergonomic alias — Drop-in net.IP-typed sibling for the existing WithLocalAddress(string) option. Lets callers who already hold a parsed IP (rotating from a precomputed pool, returned by an upstream allocator) skip the String() round-trip. Same internal storage as the string form, so mixing the two is safe; nil net.IP is a no-op so option chains built conditionally don't accidentally clobber a previously-set address.@httpcloak/win32-arm64 removed from npm optionalDependencies — The package was never built by CI (only linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64 are in the publish matrix), but the main httpcloak package's optionalDependencies listed it. npm silently skipped the missing package, so most users didn't notice; yarn classic and other strict optional-deps handlers errored out at install time even on supported platforms, blocking adoption. The phantom entry is now removed and the empty bindings/nodejs/npm/win32-arm64/ directory is cleaned up. Windows-on-ARM64 users who were broken anyway now get a clearer "no matching platform binary" error at runtime instead of an "ENOENT from npm" at install. If/when CI adds an aarch64-w64-mingw32-gcc cross-compiler step or a native ARM64 Windows runner, this can be re-added.prioritized_stream_id was hardcoded to 0, which is silently dropped by H3 fingerprinters because real Chrome never emits PRIORITY_UPDATE for stream 0 — Chrome's 0-RTT probe burns that bidi ID, and the first real request lands on stream 4. (2) The priority field value was hardcoded to "u=0, i", only matching Chrome for document navigations. After the fix, PRIORITY_UPDATE is emitted lazily just before the first request's HEADERS frame, with the prioritized_stream_id matching the actual stream the request is on, and the priority field value derived from the request's priority: HTTP header (which already comes from the per-resource-type priority_table). Net wire change: h3_text now contains the visible |984832| token between GREASE and the pseudo-order, matching real Chrome 147+ H3 captures byte-for-byte. Lives in the sardanioss/quic-go v1.2.25 bump.client.Client.DoStream now applies and stores cookies via the jar — The lower-level Go client.Client had cookie-jar parity on Do() since the jar shipped, but DoStream skipped both halves: it didn't add Cookie: from the jar to the request, and it didn't fold Set-Cookie: from the streamed response back into the jar. Sessions that authenticated via Do() and then issued a streaming request silently lost their auth state on the wire, and any cookies set by streamed responses vanished. Both halves now mirror the existing Do() paths in client.go. Session-level (session.RequestStream) and all language bindings already had parity, so this only affected Go users on the lower-level client API.WithLocalAddress is set — The doc comment has claimed Works with IP_FREEBIND on Linux since v1.5.x, but the codebase never set the sockopt. Operators relying on the documented behaviour to bind to a routed-but-not-locally-configured IPv6 address (the documented IPv6-prefix-rotation use case) hit EADDRNOTAVAIL unless they had net.ipv4.ip_nonlocal_bind=1 set globally or ran with CAP_NET_ADMIN. Fixed: a Linux-only applyFreebind helper now sets IP_FREEBIND (15) and IPV6_FREEBIND (78) on every TCP dial socket and UDP listen socket created when LocalAddress is non-empty. Wired into all three transports (H1/H2 direct + proxy paths, H3/QUIC UDP listen) and the SOCKS5 dialer. Non-Linux platforms get a no-op stub. Conditional gate: freebind is only applied when LocalAddress is set, so default callers see zero behaviour change.Nothing published for this version
…/ Cookie[] / List), closing the v1.6.1 deprecation cycle. The flat name->value dict shape is gone. Same change for the singular get_cookie(name) / get…
Build any browser fingerprint from JSON
describe_preset(name) emits every effective fingerprint field as a
JSON document. Mutate, load_preset_from_json, register, use. Same
workflow across Python / Node.js / .NET / Go. Round-trip byte-equal.
Per-resource-type H2 stream priority (Issue #56)
Chrome 141..147 desktop/android and Firefox 148 now emit a distinct
RFC 7540 stream weight + RFC 9218 priority: header per
Sec-Fetch-Dest. Safari and iOS variants stay opted out
(NoRFC7540Priorities=true). 14-dest default table inherits when a
preset doesn't define its own.
Caller-supplied headers respect HPACK position
cache-control / content-type / content-length / origin / referer /
cookie now land at their real-Chrome HPACK slot instead of being
appended after the preset's last entry.
Per-request timeout uniformly seconds
Python Session.get/post/etc, .NET Session.Get/Post/etc, and Node.js
Session.get/post all accept timeout in seconds, matching
Session(timeout=). Three coordinated bugs fixed across bindings and
clib async paths.
Cookie API close-out (BREAKING)
get_cookies() / getCookies() / GetCookies() now return cookie
objects with full metadata (List[Cookie] / Cookie[] / List),
closing the v1.6.1 deprecation cycle. The flat name->value dict
shape is gone. Same change for the singular get_cookie(name) /
getCookie(name) / GetCookie(name) -> Cookie object or null.
Migration: cookies.find(c => c.name == 'foo')?.value or equivalent.
JSON preset loader hardening
RegisterStrict() rejects (a) name collision with a built-in,
(b) duplicate custom-name registration, (c) empty name. clib
loader paths use it. BuildPreset gains an inheritance-loop
walker (rejects based_on chains that re-enter themselves) and
early ParseJA3 validation (malformed JA3 errors at load time
instead of mid-handshake).
http2.akamai shorthand authoritative override
When a custom preset spec inherits from a built-in AND sets
http2.akamai to a captured shorthand, the SETTINGS values +
WINDOW_UPDATE + stream weight + pseudo-order from the shorthand
now win over inherited discrete fields for the slots the
shorthand specifies. Previously the discrete zero defaults that
describe_preset always emits silently clobbered the captured
shorthand values.
Dependency bump: sardanioss/quic-go v1.2.24
Picks up the per-connection QUIC transport parameters work
(a8287c14). Removes the long-standing local-fork replace
directive that previously made go install impossible.
Other notable
get_cookies() / getCookies() / GetCookies() now return cookie objects with full metadata — Completes the deprecation cycle that began in v1.6.1. The flat name→value dict format is gone; the methods now return what get_cookies_detailed() etc. used to return: List[Cookie] (Python), Cookie[] (Node.js), List<Cookie> (.NET). The deprecation warnings (DeprecationWarning / process.emitWarning / [Obsolete]) are removed accordingly. The same change applies to the singular get_cookie(name) / getCookie(name) / GetCookie(name), which now return a Cookie object (or null) instead of just the value string. Migration: cookies['name'] → next((c.value for c in cookies if c.name == 'name'), None), or use c = s.get_cookie('name'); v = c.value if c else None.RegisterStrict(name, preset) errors on (a) name already registered as a custom preset, (b) name collides with a shipped built-in, (c) empty name. The clib loader paths (httpcloak_preset_load_file, httpcloak_preset_load_json) now use it so user-supplied specs can no longer accidentally shadow chrome-latest or silently overwrite a previous registration. BuildPreset gains two more guards: an inheritance-loop walker (preset chains that re-enter themselves are rejected with a clear error before the build proceeds) and early JA3 format validation via ParseJA3 (malformed JA3 strings now error at load time with the parser message, not as an opaque TLS handshake failure later).http2.akamai shorthand now authoritatively overrides inherited discrete settings — When a custom preset spec inherits from a built-in (the documented describe_preset → mutate JSON → load_preset_from_json workflow) AND sets http2.akamai to a captured shorthand, the SETTINGS values + WINDOW_UPDATE + stream weight + pseudo-order from the shorthand now win over the inherited discrete fields for the slots the shorthand specifies. Previously the discrete fields (which describe_preset always emits, including zero defaults) were applied last and overwrote the shorthand's values, so a user pasting an akamai capture would silently get the parent preset's values on the wire instead of the captured ones. Fix: parse the shorthand into a presence-aware struct (ParseAkamaiDetailed returns which SETTINGS IDs were explicitly present), apply discrete fields first only for slots the shorthand didn't cover, then overlay shorthand values for the slots it did. Discrete fields still apply normally when no shorthand is provided.document/iframe/object/embed/style → u=0 (256), script/font/empty/preload-as=fetch → u=1 (220), manifest/image → u=2 (183), video/audio/track/async-defer-script → u=3 default (147), worker/prefetch/beacon → u=4 (110). The previous single-weight model emitted weight=256, exclusive=true on every HEADERS frame regardless of dest. New H2FingerprintConfig.PriorityTable map[string]ResourcePriority carries {Urgency, Incremental, EmitHeader} per dest; the deterministic formula weight = 256 - (urgency × 73) / 2 derives the H2 wire weight, and PriorityHeaderFromResource renders the matching priority: HTTP header per the four RFC 9218 emission rules. Wire-up: a new per-request HeaderPriorityFunc callback on the underlying H2 transport (sardanioss/net v1.2.6) consults the table by Sec-Fetch-Dest, returning a fresh PriorityParam for each request — same connection, different streams, distinct priorities. Resolution rule: a preset that defines its own PriorityTable uses it as-is; a preset without one inherits a package-level default 14-dest table — but only when it uses RFC 7540 priorities (NoRFC7540Priorities=false). Safari, iOS Chrome, and iOS Safari all carry NoRFC7540Priorities=true and stay opted out (they don't emit RFC 7540 PRIORITY frames at all). Setting PriorityTable to a non-nil empty map disables the default for a single preset. Effect on shipping presets: every chrome-* desktop/android variant (chrome-141 through chrome-147) and Firefox 148 now emit per-dest priorities by default, matching real browser behaviour. JSON spec gains priority_table field on the HTTP/2 section; Describe() round-trips it byte-equal. New API surface: Preset.H2HasPriorityTable(), Preset.H2PriorityFor(dest), PriorityFromUrgency(urgency), PriorityHeaderFromResource(rp), DefaultPriorityTable(). Tests cover the formula across all 8 urgencies, every emission rule combination, full round-trip, end-to-end wire-frame capture against a local raw-framer server for every dest, default-inheritance for legacy Chrome and Firefox, NoRFC7540 opt-out for Safari/iOS variants, explicit-empty-disables override, unknown-dest fallback, per-request distinctness on a pooled connection, and concurrent request stress under -race.Sec-Fetch-Dest / Sec-Fetch-Mode / Sec-Fetch-Site are no longer clobbered by the XHR sniff — When the auto-sniff decided a request was XHR, it forced mode=cors, dest=empty, site=cross-site even if the caller had explicitly pinned a different value (e.g. dest=image for browser sub-resource emulation). Now the sniff coercion only fills in headers the caller didn't supply; explicit pins win. Required for the priority-table architecture above to be useful — power users can now request browser sub-resource fetches like <link rel=preload as=image>, <script src>, <link rel=manifest>, etc., and get the matching wire priority.chrome-148-ios preset — New iOS Chrome 148 fingerprint with refreshed User-Agent, navigation header set, HTTP/2 wire shape, and HTTP/3 QUIC flow-control windows. chrome-latest-ios / ios-chrome-latest now resolve to it.H3FingerprintConfig.QUICInitialStreamReceiveWindow + QUICInitialConnectionReceiveWindow — New optional pointer fields for per-preset QUIC flow-control windows. nil-default leaves quic-go defaults in place, so existing presets are unchanged. JSON spec gains matching quic_initial_stream_receive_window / quic_initial_connection_receive_window keys; Describe() emits them only when set.chrome-147 / chrome-147-{windows,linux,macos,ios,android} presets shipped as JSON files in fingerprint/embedded/ and auto-registered at package init via //go:embed. All *-latest aliases now resolve to Chrome 147 via thin LookupCustom wrapper factories that delegate to the embedded JSON. The //go:embed mechanism is the future home for monthly Chrome bumps — header-only diffs ship as JSON files instead of Go-code edits.describe_preset / describePreset / Describe — flatten any preset to JSON for save / edit / reload — New fingerprint.Describe(name) Go API plus matching httpcloak_describe_preset clib export and bindings (Python describe_preset(name), Node.js describePreset(name), .NET CustomPresets.Describe(name)). Returns a fully-resolved JSON document for any registered preset (built-in or runtime-loaded): inheritance is collapsed, getter fallbacks (H2Config / H3Config nil → Chrome defaults) are emitted explicitly, header values map keys are sorted alphabetically, and HeaderOrder slice order is preserved. The output round-trips byte-equal through LoadPresetFromJSON → BuildPreset → Describe, so it can be saved, hand-edited, reloaded as a custom preset, and re-described without drift. Two consecutive calls return byte-identical bytes (no map-iteration leakage). Empty/zero TCPFingerprint is omitted; the HTTP3 section appears only when SupportHTTP3=true. Unregistered utls ClientHelloIDs (e.g. randomized variants or hand-built IDs) error rather than silently corrupt JSON. JA3-defined presets dump to tls.ja3 + tls.ja3_extras (never client_hello). Verified against all 53 built-in presets in Go, Python, Node.js, and .NET — strict round-trip passes for every name in Available() including -latest aliases. The Node.js export uses the leak-safe HeapStr koffi disposable from issue #48; Python uses _ptr_to_string; .NET uses Native.PtrToStringAndFree. Internal helper: new ClientHelloIDName(id) inverse lookup over the canonical-name map, with concrete names taking precedence over -auto aliases (so HelloFirefox_Auto resolves to firefox-120, not the alias).WithDisableHTTP3() session option — Disables HTTP/3 (QUIC) while keeping H1/H2 auto-negotiation. Useful when binding to a local address that doesn't support UDP or when QUIC is unreliable on the network. Previously the only way to avoid H3 was WithForceHTTP2() which locked out H1.BuildPreset path accepts a JSON spec (TLS, H2, H3, QUIC, headers, header order, TCP fingerprint) and registers named presets at runtime. Exposed via httpcloak.loadPreset(filePath) / loadPresetFromJSON(jsonData) / unregisterPreset(name) in Python, Node.js, and .NET. Supports inheritance from built-in presets, deep-clone on lookup, mutual exclusion between ja3 + explicit TLS fields, and PSK session resumption for JA3-defined presets. Example JSON spec files ship under examples/presets/ (Chrome 146 Linux, Safari 18, Firefox 148).PresetPool for rotation — Load a JSON pool file containing multiple presets and pick round-robin or random. All presets auto-register on construction; name is returned verbatim for Session(preset: ...). Available in all bindings. Hardened against nil presets, empty pools, constructor overflow, and orphaned registrations.H2FingerprintConfig / H3FingerprintConfig types — Explicit per-preset configuration for HTTP/2 settings, header tables, priority frames, pseudo-header order, QPACK settings, and QUIC transport parameters. Replaces hardcoded values scattered across http2_transport.go, http3_transport.go, and pool builders with preset getters. All 30 built-in presets now carry explicit H2 configs; Safari/iOS presets gained explicit H3 configs replacing the prior heuristic fallback.key_share_curves, delegated_credential_algorithms, and full QUIC parameters.PresetPool lifecycle (load/pick/random/next/get/close) and the custom-preset registry are surfaced through the C API and exposed in Python/Node.js/.NET.fetchMode / fetch_mode knob on every request method — Escape hatch for requests where the auto-sniff can't pick the right Sec-Fetch-Mode. Accepts "cors", "no-cors", "navigate", or "websocket" and is available as a kwarg (Python fetch_mode), option field (Node.js fetchMode), and parameter (.NET fetchMode:) on every Get/Post/Put/Patch/Delete/Head/Options/Request + Async/Fast/Stream variant. Injects Sec-Fetch-Mode + a coherent Sec-Fetch-Dest when the user didn't supply them, so the final header set stays self-consistent.timeout semantics consistent across Python, Node.js, and .NET — Three coordinated bugs surfaced from one root cause (the clib has different unit conventions on its sync vs. async request paths): (1) Python Session.get(url, timeout=30) routed through Session.request() which forwarded the value as-is into the sync request_config.timeout field that the C side interprets as milliseconds, so a 30-second-intent call fast-failed in 30 ms. (2) .NET Session.Get(url, timeout: 30) had the identical issue at bindings/dotnet/HttpCloak/Session.cs:530. (3) Node.js Session.get(url, { timeout }) and Session.post(url, { timeout }) never destructured timeout from the options object, silently dropping the value; the underlying clib httpcloak_get_async / httpcloak_post_async paths parsed options.Timeout but never enforced it on the request context. Fix: Python Session.request() and .NET Session.Request() now multiply timeout * 1000 at the boundary before stuffing the JSON config (sync C paths read ms). Node.js get() / post() destructure timeout and forward as reqOptions.timeout. Clib get_async / post_async now layer context.WithTimeout(time.Second), matching the existing request_async unit. Public API across all bindings is now uniformly seconds (matching Session(timeout=)). Verified end-to-end: s.get(url, timeout=30) returns 200 promptly; s.get(url, timeout=1) against a 2-second sleep endpoint fast-fails in ~1 second.cache-control: max-age=0 on an F5 reload, content-type on a POST, or cookie on a follow-up request), the magic per-request Header-Order: key was being populated from the preset's header values list (which only enumerates headers Chrome sends every time) instead of the full HPACK position table (which also reserves slots for situational headers). The forked H2 encoder then appended the unknown header after the last value-list entry, producing a wire ordering distinguishable from real browsers — cache-control ended up after priority instead of right after :path. Three call sites now use Preset.H2HeaderOrder() (the complete position table including cache-control, content-type, content-length, origin, referer, cookie, and priority): transport/transport.go:1869, client/client.go:1500, client/client.go:1585. Default fresh-nav requests stay byte-identical because the encoder skips order entries with no matching req.Header key. New regression test TestUserSuppliedCacheControl_RespectsHPACKPosition pins cache-control's wire position relative to :path / sec-ch-ua / priority.Session(retry: int = 3) and Node.js's { retry = 3 } destructuring defaults always wrote retry=3 into the session config, so callers that never asked for retries quietly fired 4 requests per failed call (1 attempt + 3 retries on the default [429, 500, 502, 503, 504] status list). Worse, this hit POST/PUT/PATCH the same as GET/HEAD — a clear idempotency violation that could double-charge or duplicate writes. Root cause was a binding-level default disagreement: .NET correctly defaulted to 0, Python and Node.js defaulted to 3. Both bindings now default to 0 (matching .NET); enabling retry is opt-in via retry=N / { retry: N }. Three regression locks added so this can't drift back: a Python signature test (internal_tests/python/test_retry_default.py), a Node.js source-pattern test (internal_tests/nodejs/test_retry_default.js), and a Go-level option-chain test (retry_default_test.go) that pins the default at every layer from WithRetry / WithoutRetry down through NewSession and into the protocol.SessionConfig that drives the retry loop. Behavior change: callers that relied on the implicit default-3 retry now see 0 retries; pass retry=3 explicitly for the old behavior.tls: internal error on every handshake — Firefox 141+ ships JA3s starting with 4588-29-23-24-25-256-257. Our ParseJA3 defaulted KeyShareCurves to 1, so the resulting spec carried a single MLKEM key share. utls' TLS 1.3 client handshake then trips its keyShareKeys.ecdhe == nil consistency check (handshake_client_tls13.go:63) — the preset path that generates MLKEM key shares populates KeyShareKeys.MlkemEcdhe but not the legacy Ecdhe field, while the consistency check still requires Ecdhe. The result was local error: tls: internal error before any wire bytes left the socket. Real Firefox and Chrome always pair the MLKEM key share with an X25519 share anyway, so the fix is to auto-bump KeyShareCurves to 2 in ParseJA3 when the first non-GREASE curve is X25519MLKEM768 (0x11EC) or X25519Kyber768Draft00 (0x6399). Explicit JA3Extras.KeyShareCurves values are still honored. Added regression tests TestParseJA3_HybridPQAutoBumpsKeyShares, TestParseJA3_HybridPQRespectsExplicitKeyShareCurves, and TestParseJA3_NoBumpWithoutHybridPQ.google_connection_options regression (post-1.6.1-beta.3) — Commit 7465c7e (in v1.6.1) added QUIC transport parameter 0x3128 (google_connection_options) with value "B2ON" to the Chrome H3 fingerprint. The value was wrong: in QUICHE, B2ON is the "Enable BBRv2" option, only sent by Chrome instances launched with --enable-features=QuicConnectionOptions=B2ON or a Finch override — vanishingly rare in real traffic. Stable Chrome's actual default is "ORIG" (origin-frame experiment hint). Some QUIC frontends accepted the handshake fine but silently dropped follow-up frames for non-trivial requests, manifesting as a 30s MaxIdleTimeout. Reverted the value to "ORIG"; added a transport-package regression test (TestBuildChromeTransportParams_GoogleConnectionOptions) that locks the wire bytes so this can't drift back silently.https://A → https://B → http://C → https://D forwarded whatever Referer and Authorization headers the caller set on the first hop all the way through, including to the plain-HTTP hop. Real browsers (Chrome's default strict-origin-when-cross-origin referrer policy, plus WHATWG Fetch §4.3 "HTTP-redirect fetch") strip Referer entirely on any https → http transition and strip Authorization / Proxy-Authorization on any scheme downgrade or cross-origin redirect. curl ≥7.58 does the same for auth. session.requestWithRedirects and the parallel redirect loop in client.Client.doOnce now both apply this scrubbing. Cookie was already rebuilt from the cookie jar per-hop and the jar's Secure gate was already correct — those paths are unchanged.bindings/nodejs/lib/index.js that returned "str" let koffi copy the C string into a JS string while dropping the original pointer, which Go had allocated with C.CString (malloc). The pointer was never fed back to httpcloak_free_string, so each Session.get/post/request, getCookies, session.refresh, proxy getters, header-order getters, stream metadata, session save/marshal, local-proxy stats — 26 functions in all — silently leaked a few KB to tens of KB per call, producing significant RSS growth under sustained traffic. Fixed by wrapping "str" in a koffi disposable type (HeapStr) whose auto-invoked disposer is httpcloak_free_string, so every C→JS conversion immediately frees the source allocation. Zero call-site changes; Python and .NET already freed correctly via their own helpers and were not affected.httpcloak_post_raw → session.Do → transport.applyPresetHeaders) had an Accept-only sniff that picked Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document, and Sec-Fetch-Site: none for any POST without an explicit Accept header. Python's json= kwarg set Content-Type: application/json but not Accept, so every JSON POST emitted navigation headers — an obvious mismatch since browsers send CORS headers for fetch/XHR. The sniff now considers HTTP method, Content-Type, Accept, and any user-supplied Sec-Fetch-* headers, and applyPresetHeaders applies a coherent CORS header block (mode=cors, dest=empty, no upgrade-insecure-requests) when the request looks like fetch()/XHR. The direct-Go-client.Client path was fixed alongside the transport path so the two stay in lockstep. Explicit Sec-Fetch-Mode: navigate from the user still forces navigation (e.g. SPA mimicking a form submit).Max-Age > int32.MaxValue crash — CookieData.MaxAge, Cookie.MaxAge, and the SetCookie(maxAge:) parameter were typed as int. Servers that advertise 100-year-lifetime cookies (Max-Age=3153600000) triggered System.Text.Json to throw "The JSON value could not be converted to System.Int32" during deserialization, taking down sync and async request paths. All three are now long. Wire format unchanged; existing scripts pass int literals without change.body_encoding: "base64"; Python, Node.js, and .NET decoders decode on receipt. Covers the main request/response path, httpcloak_upload_finish, and the Session.post() / Session.request() binary flows.httpcloak_{get,post,request}_raw which takes (ptr, len) directly, matching Python.applyNavigationModeHeaders and the client layer were applying hardcoded Chrome Accept / Accept-Language / Accept-Encoding values on top of the preset's own headers, silently clobbering Firefox/Safari/iOS presets. Now uses preset values when present, falls back to Chrome only when the preset doesn't define that header. Pseudo-header order override in client and transport layers is also fixed — PseudoHeaderOrder from the preset now survives through both layers.DisableCookieSplit: true — Pool-path HTTP/2 was sending cookies as separate HPACK entries instead of a single entry like real Chrome. Detectable by passive H2 fingerprinters.CONNECT requests did not include Connection: keep-alive, causing some proxies to close the tunnel after the CONNECT response.Close() and request could dereference a nil map.LookupCustom did not deep-clone presets — Returning a shared pointer let subsequent mutations leak across sessions. Now returns a deep copy.describe_preset now emits the effective priority_table, including the inherited package default — Previously Describe() only emitted priority_table when the preset carried an explicit one, so a Chrome 146 dump (which inherits the 14-dest default) returned JSON that omitted the field — confusing for users who wanted to tweak just one entry, since the describe → edit → reload workflow had nothing to edit. flattenHTTP2 now resolves the same way the runtime does: explicit table wins; otherwise, RFC 7540 presets emit the package default; NoRFC7540Priorities=true presets (Safari, iOS Chrome, iOS Safari) still omit the field because they don't carry an RFC 7540 PRIORITY frame at all. Empty PriorityTable map is now treated identically to nil at the resolution layer (both fall through to default), simplifying the round-trip semantics. Round-trip stability locked in tests across all 50+ built-in presets.examples/python-examples/17_tweak_fingerprint.py, examples/js-examples/18_tweak_fingerprint.js, and examples/csharp-examples/TweakFingerprint.cs demonstrate the four-recipe describe → edit → load workflow: bump per-resource H2 priority, customize HPACK header order, import an externally-captured JA3 + Akamai fingerprint, and clean up via unregister_preset. README gains a flagship "Build Any Browser Fingerprint From JSON" feature section plus a compact "Custom Preset Edit Points" reference table.key_share_curves, delegated_credential_algorithms, and QUIC H3 fields (connection_id_length, max_datagram_frame_size) are now first-class JSON fields. Inheritance, mutual exclusion validation, and deep-copy behavior are hardened in the loader.The existing getCookies() / getCookie() methods continue to return the old flat format (name→value dict / string) with a deprecation notice — in a fut…
sec-ch-ua brand rotation ("Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146") and User-Agent version bump. TLS and HTTP/2 fingerprints are identical to Chrome 145/144/143. All -latest aliases now resolve to Chrome 146. All code examples updated to use chrome-latest to avoid version-specific churn.getCookiesDetailed() / getCookieDetailed() — New methods that return Cookie objects with full metadata (domain, path, expires, maxAge, secure, httpOnly, sameSite). Available in all bindings. The existing getCookies() / getCookie() methods continue to return the old flat format (name→value dict / string) with a deprecation notice — in a future release they will return the same format as the detailed methods.google_connection_options QUIC transport parameter — Chrome sends google_connection_options (0x3128) with value "B2ON" in QUIC handshakes. This was the last missing Chrome-specific transport parameter identified in a full fingerprint audit. (Note: subsequently corrected to "ORIG" in the Unreleased block — see fix entry above.)cookie, authorization, and proxy-authorization now use the HPACK "Never Indexed" wire encoding (0x10 prefix) matching Chrome's behavior. Previously used "Without Indexing" (0x00 prefix) which passive H2 fingerprinters can distinguish.tcp_df option in Python and Node.js bindings — The DF (Don't Fragment) bit was missing from the Python and Node.js session constructors. Now all 5 TCP fingerprint fields are exposed in all bindings.Session constructor and SessionConfig class now expose tcpTtl, tcpMss, tcpWindowSize, tcpWindowScale, and tcpDf parameters.getCookies() flattened it to a name→value dict, losing domain/path/expiry and causing last-write-wins collisions when two domains set a cookie with the same name. setCookie() now accepts domain/path/flags for domain-scoped cookies — setCookie("name", "value") still works unchanged. deleteCookie() properly removes cookies (was setting to empty string) and accepts an optional domain parameter. clearCookies() calls the Go core directly (was doing a broken client-side loop). All existing scripts continue to work — getCookies() still returns a flat dict, getCookie() still returns a string. Wire behavior, session serialization, and per-request cookies parameter are unchanged.http2.Transport was missing DisableCookieSplit: true, causing cookies to be sent as separate HPACK entries instead of a single entry like real Chrome. Detectable by passive H2 fingerprinters.WithTCPFingerprint() (Go) or tcp_ttl/tcp_mss etc. in bindings.log.Printf warnings about insufficient kernel UDP buffer sizes are removed. setReceiveBuffer/setSendBuffer still attempt to increase buffers best-effort; failures are silently handled by userspace buffering.Fix TCP fingerprint override silently ignored (missing needsConfig check)
WithTCPFingerprint in Go or tcp_ttl/tcp_mss/tcp_window_size/tcp_window_scale options in bindings.FetchModeNoCors — Simulate subresource loads (<script>, <link>, <img>) with sec-fetch-mode: no-cors and content-type-appropriate Accept headers. Use with FetchDest field to set sec-fetch-dest (script, style, image).SetForceProtocol() — Switch HTTP protocol version (H1/H2/H3) at runtime without creating a new client. Useful for mimicking Chrome's H2→H3 alt-svc upgrade pattern.writeHeadersInOrder "remaining headers" loop wrote headers not in the preset order but did not mark them in the tracking map. The fallback "ensure Content-Length" block then wrote Content-Length a second time. Duplicate Content-Length is an HTTP/1.1 protocol violation — nginx and other strict servers return 400 Bad Request. This affected all H1 POST/PUT/PATCH requests with a body through all language bindings.applyPresetHeaders always applied Navigate mode headers (sec-fetch-mode: navigate, upgrade-insecure-requests: 1) regardless of request type. API calls via Python/Node.js/.NET bindings emitted browser navigation headers on JSON requests — a clear protocol mismatch since real browsers send CORS headers for fetch/XHR. Now auto-detects CORS mode from the user's Accept header (application/json, */*, etc.) and adjusts sec-fetch headers accordingly.H3HeaderOrder from presets. Chrome uses the same request_->extra_headers ordered vector for both H2 and H3 (confirmed from Chromium source). The previous H3-specific order was a stale artifact from an upstream tool whose output had been observed-but-incorrectly-ordered.Nothing published for this version
Nothing published for this version
Fix query parameters duplicated in URL for .NET async methods (GetAsync, PostAsync) — params were applied in the method then passed again to RequestAs
GetAsync, PostAsync) — params were applied in the method then passed again to RequestAsync which applied them a second time (only affected async path with explicit timeout)SetProxy() and SetPreset() losing insecureSkipVerify setting — recreated child transports started with default false, ignoring the parent's verify: false settingparameters type from Dictionary<string, string> to IEnumerable<KeyValuePair<string, string>> across all request methods (source-compatible, users can now pass ordered collections like List<KeyValuePair<>> for order-sensitive APIs)Custom JA3 fingerprinting — Override the preset's TLS fingerprint with a custom JA3 string. Supports all 25+ known TLS extensions, GREASE filtering, a
WithCustomFingerprint in Go and ja3 option in all bindings (Python, Node.js, .NET, clib).WithCustomFingerprint in Go and akamai option in all bindings.tls_signature_algorithms, tls_alpn, tls_cert_compression, tls_permute_extensions. Available via extra_fp dict in bindings or CustomFingerprint struct fields in Go.fingerprint/ja3.go) — Converts JA3 strings to uTLS ClientHelloSpec with extension ID to TLSExtension mapping for 25+ known extensions, GREASE handling, and Chrome-like defaults for signature algorithms, ALPN, and cert compression.fingerprint/akamai.go) — Converts Akamai HTTP/2 fingerprint strings to HTTP2Settings + pseudo-header order.signature_algorithms_cert) now uses a broader Chrome-like list including PKCS1WithSHA1 for legacy certificate chain verificationkey_share) now generates a key share only for the first preferred curve, matching real browser behavior (previously generated for all curves, which was a detectable fingerprint signal)DoStream missing configErr check — invalid Akamai fingerprint errors were silently ignored for streaming requestsParseJA3 mutating caller's *JA3Extras struct when filling in defaults — now makes a shallow copySetProxy() and SetPreset() silently dropping custom fingerprint config — recreated transports with nil config, losing CustomJA3, CustomH2Settings, speculative TLS, key log writer, and other settingsFork() dropping custom fingerprint settings — forked sessions now copy the parent's transport config (including custom JA3, H2 settings, pseudo-header order)extra_fp silently ignored when neither ja3 nor akamai is set — tls_permute_extensions and other extra options now work standaloneNothing published for this version
Chrome 145 presets — Added chrome-145, chrome-145-windows, chrome-145-linux, chrome-145-macos, chrome-145-ios, chrome-145-android browser presets with
chrome-145, chrome-145-windows, chrome-145-linux, chrome-145-macos, chrome-145-ios, chrome-145-android browser presets with updated TLS fingerprints and HTTP/2/H3 settings.chrome-144 to chrome-145Nothing published for this version
`session.Fork(n)` — Create N sessions sharing cookies and TLS session caches but with independent connections. Simulates multiple browser tabs from th
session.Fork(n) — Create N sessions sharing cookies and TLS session caches but with independent connections. Simulates multiple browser tabs from the same browser for parallel scraping. Available in Go, Python, Node.js, and C#.session.Warmup(url) — Simulate a real browser page load by fetching HTML and all subresources (CSS, JS, images, fonts) with realistic headers, priorities, and timing. Populates TLS session tickets, cookies, and cache headers before real work begins. Available in Go, Python, Node.js, and C#.enable_speculative_tls.switch_protocol on Refresh() — Switch HTTP protocol version (h1/h2/h3) when calling Refresh(), persisting for future refreshes.-latest preset aliases — chrome-latest, firefox-latest, safari-latest aliases that automatically resolve to the newest preset version.available_presets() returns dict — Now returns a dict with protocol support info ({name: {h1, h2, h3}}) instead of a flat list.Content-Type: application/json when body is a JSON object/dict.Dispose() is missed.disable_ech toggle — Disable ECH lookup per-session for faster first requests when ECH is not needed.cache-control: max-age=0 after Refresh() — Automatically adds cache-control header to requests after Refresh(), matching real browser F5 behavior.WithLocalAddress in Go and local_address option in bindings.key_log_file option and SSLKEYLOGFILE environment variable support for Wireshark TLS inspection.httpcloak_fast_*) for high-throughput transfers via C FFI.chrome-144-ios, chrome-144-android, safari-18-ios presets.chrome-131/chrome-143 to chrome-latestSOCKS5UDPConn with udpbara for H3 proxy transportconnsMu during TCP+TLS dial so other requests aren't blockedmin(remaining_budget/remaining_addrs, 10s)Content-LengthRefresh()/recreateTransport()Refresh() by re-adding missing preset configurationsbufio.Reader data loss in proxy CONNECT for H1 and H2Connection header, H2 cleanup race, dead MASQUE codenet/url for proper base URL joiningquic.Transport goroutine leak in SOCKS5 H3 proxy pathverify: false not disabling TLS certificate validationconnect_to domain fronting connection pool key sharingUnsafeRelaxedJsonEscaping for all JSON serializationX-HTTPCloak-TlsOnly header support in LocalProxychrome-131/chrome-143) across all bindingsget_async()/post_async() methodshttpcloak_fast.go source filechrome-131 preset from all binding defaultsClose() blocking indefinitely on QUIC graceful drainwg.Wait() in goroutines now uses channel+select on ctx.Done()time.Sleep() in goroutines replaced with select { case <-time.After(): case <-ctx.Done(): }http.ReadResponse() on proxy connections now sets conn.SetReadDeadline()Close() wrapped in closeWithTimeout() in both Refresh() and Close() pathsNothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Baseline release. This changelog begins tracking changes from this version forward.
Baseline release. This changelog begins tracking changes from this version forward.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →