Summary
The Rust JA4H implementation includes HTTP/2 pseudo-headers such as :method,
:scheme, :authority, and :path in both the JA4H header count and header
hash. Python and Wireshark exclude these fields, so Rust produces different JA4H
fingerprints for the same HTTP/2 request.
Affected code
Rust collects http2.header.name and filters only cookie and referer:
rust/ja4/src/http.rs:125-145:
let headers = http2
.values("http2.header.name")
.filter_map(|s| {
if s == "cookie" {
has_cookie_header = true;
None
} else if s == "referer" {
has_referer_header = true;
None
} else {
Some(s.to_owned())
}
})
.collect();
Other implementations:
- Python drops names beginning with
: in python/ja4h.py:47-50.
- Wireshark drops pseudo-headers in
wireshark/source/packet-ja4.c:1195-1208,
because splitting a pseudo-header name on : yields an empty first component
and the code rejects empty names.
Evidence
For pcap/chrome-cloudflare-quic-with-secrets.pcapng, Python and Wireshark
agree on the HTTP/2 JA4H:
ge20nn12enus_60f823d07c94_000000000000_000000000000
References:
python/test/testdata/chrome-cloudflare-quic-with-secrets.pcapng.json
wireshark/test/testdata/chrome-cloudflare-quic-with-secrets.pcapng.json
Rust emits:
ge20nn16enus_0f5a7a41a252_000000000000_000000000000
Reference:
rust/ja4/src/snapshots/ja4__insta@chrome-cloudflare-quic-with-secrets.pcapng.snap
The first segment differs (ge20nn12enus vs ge20nn16enus), consistent with
Rust counting four HTTP/2 pseudo-headers.
Expected behavior
I assume HTTP/2 pseudo-headers should not contribute to:
- the JA4H header count
- the JA4H header-name hash
- raw
ja4h_r / ja4h_ro header-name lists
Suggested resolution
-
Align implementations - filter pseudo-headers in HttpStats::from_http2,
before adding names to the regular header list.
-
Add a regression test using an HTTP/2 request containing the common pseudo
headers and assert that the Rust output matches Python/Wireshark for the
header count and header hash.
Summary
The Rust JA4H implementation includes HTTP/2 pseudo-headers such as
:method,:scheme,:authority, and:pathin both the JA4H header count and headerhash. Python and Wireshark exclude these fields, so Rust produces different JA4H
fingerprints for the same HTTP/2 request.
Affected code
Rust collects
http2.header.nameand filters onlycookieandreferer:rust/ja4/src/http.rs:125-145:Other implementations:
:inpython/ja4h.py:47-50.wireshark/source/packet-ja4.c:1195-1208,because splitting a pseudo-header name on
:yields an empty first componentand the code rejects empty names.
Evidence
For
pcap/chrome-cloudflare-quic-with-secrets.pcapng, Python and Wiresharkagree on the HTTP/2 JA4H:
References:
python/test/testdata/chrome-cloudflare-quic-with-secrets.pcapng.jsonwireshark/test/testdata/chrome-cloudflare-quic-with-secrets.pcapng.jsonRust emits:
Reference:
rust/ja4/src/snapshots/ja4__insta@chrome-cloudflare-quic-with-secrets.pcapng.snapThe first segment differs (
ge20nn12enusvsge20nn16enus), consistent withRust counting four HTTP/2 pseudo-headers.
Expected behavior
I assume HTTP/2 pseudo-headers should not contribute to:
ja4h_r/ja4h_roheader-name listsSuggested resolution
Align implementations - filter pseudo-headers in
HttpStats::from_http2,before adding names to the regular header list.
Add a regression test using an HTTP/2 request containing the common pseudo
headers and assert that the Rust output matches Python/Wireshark for the
header count and header hash.