Hi.I thought to see how claude.ai can help HAProxy and ask claude.ai to add the feature JA4H Fingerprint.
Attached the 3 patches. What's your opinion on that? Regards Aleks
From 49d0d604bb010a957fe2280a7a470d7e1541f556 Mon Sep 17 00:00:00 2001 From: Aleksandar Lazic <[email protected]> Date: Wed, 28 Jan 2026 20:05:53 +0100 Subject: [PATCH 3/3] MINOR: doc: add Doc for JA4H This Patch adds the Documentation of the feature JA4H Fingerprint. The related issue is https://github.com/haproxy/haproxy/issues/2495 Signed-off-by: Aleksandar Lazic <[email protected]> --- doc/configuration.txt | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/doc/configuration.txt b/doc/configuration.txt index 4de08f5043..9aff209bdd 100644 --- a/doc/configuration.txt +++ b/doc/configuration.txt @@ -26607,6 +26607,7 @@ http_auth_pass string http_auth_type string http_auth_user string http_first_req boolean +ja4h string method integer path string pathq string @@ -26824,6 +26825,39 @@ http_first_req : boolean from some requests when a request is not the first one, or to help grouping requests in the logs. +ja4h : string + Returns the JA4H HTTP fingerprint of the current HTTP request. JA4H is an + HTTP client fingerprinting method that creates a unique identifier based on + HTTP request characteristics. The fingerprint format is: + + {method}{version}{cookie}{referer}{hdr_count}_{al_hash}_{hdr_hash}_{ck_hash} + + Where: + - method : 2 lowercase characters representing the HTTP method + (ge=GET, po=POST, he=HEAD, pu=PUT, de=DELETE, co=CONNECT, + op=OPTIONS, tr=TRACE, xx=OTHER) + - version : 2 characters for HTTP version (10=HTTP/1.0, 11=HTTP/1.1, + 20=HTTP/2, 30=HTTP/3) + - cookie : 'c' if a Cookie header is present, 'n' otherwise + - referer : 'r' if a Referer header is present, 'n' otherwise + - hdr_count : 2-digit zero-padded count of request headers (00-99) + - al_hash : 12-character truncated hash of the Accept-Language header + value (000000000000 if absent) + - hdr_hash : 12-character truncated hash of sorted, comma-separated + header names + - ck_hash : 12-character truncated hash of sorted, comma-separated + cookie names (000000000000 if no cookies) + + Example output: "ge11cr06_8672ab955a25_580289e42181_da4ff4feb377" + + This can be useful for HTTP client fingerprinting, bot detection, and + traffic analysis. The fingerprint remains consistent for requests with + identical HTTP characteristics. + + Example: + http-request set-header X-JA4H %[ja4h] + http-request capture ja4h len 55 + method : integer + string Returns an integer value corresponding to the method in the HTTP request. For example, "GET" equals 1 (check sources to establish the matching). Value 9 -- 2.43.0
From 1864b7a62c371b2b931fd7669f16fef8595a436f Mon Sep 17 00:00:00 2001 From: Aleksandar Lazic <[email protected]> Date: Wed, 28 Jan 2026 20:05:08 +0100 Subject: [PATCH 2/3] MINOR: reg-tests: Add tests for JA4H This Patch adds the reg-test for the feature JA4H Fingerprint. The related issue is https://github.com/haproxy/haproxy/issues/2495 Signed-off-by: Aleksandar Lazic <[email protected]> --- reg-tests/sample_fetches/ja4h.vtc | 303 ++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 reg-tests/sample_fetches/ja4h.vtc diff --git a/reg-tests/sample_fetches/ja4h.vtc b/reg-tests/sample_fetches/ja4h.vtc new file mode 100644 index 0000000000..7170db291c --- /dev/null +++ b/reg-tests/sample_fetches/ja4h.vtc @@ -0,0 +1,303 @@ +varnishtest "ja4h sample fetch Test" + +feature ignore_unknown_macro + +# TEST - 1 +# Basic JA4H fingerprint with GET request, cookies, and referer +server s1 { + rxreq + txresp +} -start + +haproxy h1 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + timeout client 30s + timeout server 30s + timeout connect 30s + mode http + + frontend fe + bind "fd@${fe}" + http-request set-var(txn.ja4h) ja4h + http-response set-header x-ja4h %[var(txn.ja4h)] + + default_backend be + + backend be + server srv1 ${s1_addr}:${s1_port} +} -start + +client c1 -connect ${h1_fe_sock} { + txreq -req GET -url "/" \ + -hdr "Host: example.com" \ + -hdr "User-Agent: TestClient/1.0" \ + -hdr "Accept: text/html" \ + -hdr "Accept-Language: en-US,en;q=0.9" \ + -hdr "Cookie: session=abc123; user=test" \ + -hdr "Referer: http://example.com/page" + rxresp + expect resp.status == 200 + # JA4H format: {method}{version}{cookie}{referer}{hdr_count}_{accept_lang_hash}_{headers_hash}_{cookies_hash} + # ge = GET, 11 = HTTP/1.1, c = cookie present, r = referer present, 06 = 6 headers + expect resp.http.x-ja4h ~ "^ge11cr06_[0-9a-f]{12}_[0-9a-f]{12}_[0-9a-f]{12}$" +} -run + +# TEST - 2 +# POST request without cookies or referer +server s2 { + rxreq + txresp +} -start + +haproxy h2 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + timeout client 30s + timeout server 30s + timeout connect 30s + mode http + + frontend fe + bind "fd@${fe}" + http-request set-var(txn.ja4h) ja4h + http-response set-header x-ja4h %[var(txn.ja4h)] + + default_backend be + + backend be + server srv2 ${s2_addr}:${s2_port} +} -start + +client c2 -connect ${h2_fe_sock} { + txreq -req POST -url "/api/data" \ + -hdr "Host: api.example.com" \ + -hdr "Content-Type: application/json" \ + -hdr "Content-Length: 0" + rxresp + expect resp.status == 200 + # po = POST, 11 = HTTP/1.1, n = no cookie, n = no referer, 03 = 3 headers + expect resp.http.x-ja4h ~ "^po11nn03_000000000000_[0-9a-f]{12}_000000000000$" +} -run + +# TEST - 3 +# HEAD request with cookie but no referer +server s3 { + rxreq + txresp +} -start + +haproxy h3 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + timeout client 30s + timeout server 30s + timeout connect 30s + mode http + + frontend fe + bind "fd@${fe}" + http-request set-var(txn.ja4h) ja4h + http-response set-header x-ja4h %[var(txn.ja4h)] + + default_backend be + + backend be + server srv3 ${s3_addr}:${s3_port} +} -start + +client c3 -connect ${h3_fe_sock} { + txreq -req HEAD -url "/" \ + -hdr "Host: example.com" \ + -hdr "Cookie: token=xyz789" + rxresp + expect resp.status == 200 + # he = HEAD, 11 = HTTP/1.1, c = cookie present, n = no referer, 02 = 2 headers + expect resp.http.x-ja4h ~ "^he11cn02_000000000000_[0-9a-f]{12}_[0-9a-f]{12}$" +} -run + +# TEST - 4 +# PUT request with referer but no cookie +server s4 { + rxreq + txresp +} -start + +haproxy h4 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + timeout client 30s + timeout server 30s + timeout connect 30s + mode http + + frontend fe + bind "fd@${fe}" + http-request set-var(txn.ja4h) ja4h + http-response set-header x-ja4h %[var(txn.ja4h)] + + default_backend be + + backend be + server srv4 ${s4_addr}:${s4_port} +} -start + +client c4 -connect ${h4_fe_sock} { + txreq -req PUT -url "/resource" \ + -hdr "Host: example.com" \ + -hdr "Referer: http://example.com/edit" \ + -hdr "Content-Length: 0" + rxresp + expect resp.status == 200 + # pu = PUT, 11 = HTTP/1.1, n = no cookie, r = referer present, 03 = 3 headers + expect resp.http.x-ja4h ~ "^pu11nr03_000000000000_[0-9a-f]{12}_000000000000$" +} -run + +# TEST - 5 +# DELETE request +server s5 { + rxreq + txresp +} -start + +haproxy h5 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + timeout client 30s + timeout server 30s + timeout connect 30s + mode http + + frontend fe + bind "fd@${fe}" + http-request set-var(txn.ja4h) ja4h + http-response set-header x-ja4h %[var(txn.ja4h)] + + default_backend be + + backend be + server srv5 ${s5_addr}:${s5_port} +} -start + +client c5 -connect ${h5_fe_sock} { + txreq -req DELETE -url "/item/123" \ + -hdr "Host: api.example.com" + rxresp + expect resp.status == 200 + # de = DELETE, 11 = HTTP/1.1, n = no cookie, n = no referer, 01 = 1 header + expect resp.http.x-ja4h ~ "^de11nn01_000000000000_[0-9a-f]{12}_000000000000$" +} -run + +# TEST - 6 +# OPTIONS request +server s6 { + rxreq + txresp +} -start + +haproxy h6 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + timeout client 30s + timeout server 30s + timeout connect 30s + mode http + + frontend fe + bind "fd@${fe}" + http-request set-var(txn.ja4h) ja4h + http-response set-header x-ja4h %[var(txn.ja4h)] + + default_backend be + + backend be + server srv6 ${s6_addr}:${s6_port} +} -start + +client c6 -connect ${h6_fe_sock} { + txreq -req OPTIONS -url "*" \ + -hdr "Host: example.com" + rxresp + expect resp.status == 200 + # op = OPTIONS, 11 = HTTP/1.1, n = no cookie, n = no referer, 01 = 1 header + expect resp.http.x-ja4h ~ "^op11nn01_000000000000_[0-9a-f]{12}_000000000000$" +} -run + +# TEST - 7 +# Verify consistent hashing - same request should produce same fingerprint +server s7 { + rxreq + txresp + rxreq + txresp +} -start + +haproxy h7 -conf { + global + .if feature(THREAD) + thread-groups 1 + .endif + + defaults + timeout client 30s + timeout server 30s + timeout connect 30s + mode http + + frontend fe + bind "fd@${fe}" + http-request set-var(txn.ja4h) ja4h + http-response set-header x-ja4h %[var(txn.ja4h)] + + default_backend be + + backend be + server srv7 ${s7_addr}:${s7_port} +} -start + +client c7 -connect ${h7_fe_sock} { + txreq -req GET -url "/" \ + -hdr "Host: test.example.com" \ + -hdr "Accept: */*" \ + -hdr "Accept-Language: fr-FR" \ + -hdr "Cookie: id=12345" + rxresp + expect resp.status == 200 + expect resp.http.x-ja4h ~ "^ge11cn04_[0-9a-f]{12}_[0-9a-f]{12}_[0-9a-f]{12}$" +} -run + +client c7b -connect ${h7_fe_sock} { + # Same request again should produce identical fingerprint + txreq -req GET -url "/" \ + -hdr "Host: test.example.com" \ + -hdr "Accept: */*" \ + -hdr "Accept-Language: fr-FR" \ + -hdr "Cookie: id=12345" + rxresp + expect resp.status == 200 + expect resp.http.x-ja4h ~ "^ge11cn04_[0-9a-f]{12}_[0-9a-f]{12}_[0-9a-f]{12}$" +} -run -- 2.43.0
From a0c8b9b02049ac2c117c91b79506f0967c21aded Mon Sep 17 00:00:00 2001 From: Aleksandar Lazic <[email protected]> Date: Wed, 28 Jan 2026 13:21:08 +0100 Subject: [PATCH 1/3] MINOR: sample: Add JA4H Sample fetch This Patch adds the feature to generate JA4H Fingerprint. The related issue is https://github.com/haproxy/haproxy/issues/2495 Signed-off-by: Aleksandar Lazic <[email protected]> --- src/http_fetch.c | 289 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) diff --git a/src/http_fetch.c b/src/http_fetch.c index b8b6f616c0..da59d2de98 100644 --- a/src/http_fetch.c +++ b/src/http_fetch.c @@ -39,6 +39,7 @@ #include <haproxy/log.h> #include <haproxy/tools.h> #include <haproxy/version.h> +#include <haproxy/xxhash.h> /* this struct is used between calls to smp_fetch_hdr() or smp_fetch_cookie() */ @@ -2196,6 +2197,293 @@ static int smp_fetch_url32_src(const struct arg *args, struct sample *smp, const return 1; } +/************************************************************************/ +/* JA4H HTTP Fingerprint */ +/************************************************************************/ + +/* Comparison function for sorting strings (for JA4H) */ +static int ja4h_strcmp(const void *a, const void *b) +{ + const struct ist *s1 = (const struct ist *)a; + const struct ist *s2 = (const struct ist *)b; + size_t min_len = s1->len < s2->len ? s1->len : s2->len; + int cmp = strncasecmp(s1->ptr, s2->ptr, min_len); + if (cmp != 0) + return cmp; + return (int)s1->len - (int)s2->len; +} + +/* Convert 6 bytes to 12 hex characters */ +static void ja4h_hash_to_hex(uint64_t hash, char *out) +{ + static const char hex[] = "0123456789abcdef"; + int i; + /* Use first 6 bytes (12 hex chars) of the hash */ + for (i = 0; i < 6; i++) { + out[i * 2] = hex[(hash >> (56 - i * 8)) >> 4 & 0xf]; + out[i * 2 + 1] = hex[(hash >> (56 - i * 8)) & 0xf]; + } +} + +/* JA4H HTTP Fingerprint + * Format: {method}{version}{cookie}{referer}{num_headers}_{accept-lang-hash}_{headers-hash}_{cookies-hash} + * Example: ge11cr12_a2b1c3d4e5f6_1a2b3c4d5e6f_a1b2c3d4e5f6 + * + * - method: 2 lowercase chars (ge=GET, po=POST, he=HEAD, pu=PUT, de=DELETE, co=CONNECT, op=OPTIONS, tr=TRACE, xx=OTHER) + * - version: 2 chars (10=HTTP/1.0, 11=HTTP/1.1, 20=HTTP/2, 30=HTTP/3) + * - cookie: 'c' if Cookie header present, 'n' otherwise + * - referer: 'r' if Referer header present, 'n' otherwise + * - num_headers: 2-digit zero-padded count of headers (00-99) + * - accept-lang-hash: 12-char truncated XXH3 hash of Accept-Language value (or 000000000000 if absent) + * - headers-hash: 12-char truncated XXH3 hash of sorted, comma-separated header names + * - cookies-hash: 12-char truncated XXH3 hash of sorted, comma-separated cookie names (or 000000000000 if no cookies) + */ +static int smp_fetch_ja4h(const struct arg *args, struct sample *smp, const char *kw, void *private) +{ + struct channel *chn = SMP_REQ_CHN(smp); + struct htx *htx = smp_prefetch_htx(smp, chn, NULL, 1); + struct http_txn *txn; + struct htx_sl *sl; + struct buffer *result; + struct http_hdr_ctx ctx; + int32_t pos; + char method_str[3] = "xx"; + char version_str[3] = "00"; + char cookie_flag = 'n'; + char referer_flag = 'n'; + int hdr_count = 0; + uint64_t accept_lang_hash = 0; + uint64_t headers_hash = 0; + uint64_t cookies_hash = 0; + char accept_lang_hex[13] = "000000000000"; + char headers_hex[13] = "000000000000"; + char cookies_hex[13] = "000000000000"; + struct ist *hdr_names = NULL; + struct ist *cookie_names = NULL; + int hdr_names_count = 0; + int cookie_names_count = 0; + int hdr_names_alloc = 64; + int cookie_names_alloc = 32; + struct buffer *hdr_buf = NULL; + struct buffer *cookie_buf = NULL; + struct ist *tmp = NULL; + struct ist vsn; + int i; + + if (!htx) + return 0; + + txn = (smp->strm ? smp->strm->txn : NULL); + if (!txn) + return 0; + + sl = http_get_stline(htx); + if (!sl) + return 0; + + /* Extract method - 2 lowercase chars */ + switch (txn->meth) { + case HTTP_METH_GET: method_str[0] = 'g'; method_str[1] = 'e'; break; + case HTTP_METH_POST: method_str[0] = 'p'; method_str[1] = 'o'; break; + case HTTP_METH_HEAD: method_str[0] = 'h'; method_str[1] = 'e'; break; + case HTTP_METH_PUT: method_str[0] = 'p'; method_str[1] = 'u'; break; + case HTTP_METH_DELETE: method_str[0] = 'd'; method_str[1] = 'e'; break; + case HTTP_METH_CONNECT: method_str[0] = 'c'; method_str[1] = 'o'; break; + case HTTP_METH_OPTIONS: method_str[0] = 'o'; method_str[1] = 'p'; break; + case HTTP_METH_TRACE: method_str[0] = 't'; method_str[1] = 'r'; break; + default: method_str[0] = 'x'; method_str[1] = 'x'; break; + } + + /* Extract HTTP version */ + if (sl->flags & HTX_SL_F_VER_11) { + version_str[0] = '1'; + version_str[1] = '1'; + } else { + /* Check version string for HTTP/1.0, HTTP/2, HTTP/3 */ + vsn = htx_sl_req_vsn(sl); + if (vsn.len >= 8 && vsn.ptr[5] == '1' && vsn.ptr[7] == '0') { + version_str[0] = '1'; + version_str[1] = '0'; + } else if (vsn.len >= 6 && vsn.ptr[5] == '2') { + version_str[0] = '2'; + version_str[1] = '0'; + } else if (vsn.len >= 6 && vsn.ptr[5] == '3') { + version_str[0] = '3'; + version_str[1] = '0'; + } else { + version_str[0] = '1'; + version_str[1] = '0'; + } + } + + /* Allocate arrays for header and cookie names */ + hdr_names = malloc(hdr_names_alloc * sizeof(*hdr_names)); + cookie_names = malloc(cookie_names_alloc * sizeof(*cookie_names)); + if (!hdr_names || !cookie_names) + goto error; + + /* Iterate through all headers */ + for (pos = htx_get_first(htx); pos != -1; pos = htx_get_next(htx, pos)) { + struct htx_blk *blk = htx_get_blk(htx, pos); + enum htx_blk_type type = htx_get_blk_type(blk); + struct ist name, value; + + if (type == HTX_BLK_EOH) + break; + if (type != HTX_BLK_HDR) + continue; + + name = htx_get_blk_name(htx, blk); + value = htx_get_blk_value(htx, blk); + + /* Check for Cookie header */ + if (isteqi(name, ist("Cookie"))) { + cookie_flag = 'c'; + } + + /* Check for Referer header */ + if (isteqi(name, ist("Referer"))) { + referer_flag = 'r'; + } + + /* Check for Accept-Language header */ + if (isteqi(name, ist("Accept-Language"))) { + accept_lang_hash = XXH3(value.ptr, value.len, 0); + ja4h_hash_to_hex(accept_lang_hash, accept_lang_hex); + } + + /* Add header name to list */ + if (hdr_names_count >= hdr_names_alloc) { + hdr_names_alloc *= 2; + tmp = realloc(hdr_names, hdr_names_alloc * sizeof(*hdr_names)); + if (!tmp) + goto error; + hdr_names = tmp; + } + hdr_names[hdr_names_count++] = name; + hdr_count++; + } + + /* Sort header names alphabetically (case-insensitive) */ + if (hdr_names_count > 0) + qsort(hdr_names, hdr_names_count, sizeof(*hdr_names), ja4h_strcmp); + + /* Build comma-separated sorted header names string and hash it */ + hdr_buf = get_trash_chunk(); + if (!hdr_buf) + goto error; + + for (i = 0; i < hdr_names_count; i++) { + if (i > 0) + chunk_appendf(hdr_buf, ","); + chunk_istcat(hdr_buf, hdr_names[i]); + } + + if (hdr_buf->data > 0) { + headers_hash = XXH3(hdr_buf->area, hdr_buf->data, 0); + ja4h_hash_to_hex(headers_hash, headers_hex); + } + + /* Extract and hash cookie names if Cookie header exists */ + if (cookie_flag == 'c') { + ctx.blk = NULL; + while (http_find_header(htx, ist("Cookie"), &ctx, 0)) { + char *ptr = ctx.value.ptr; + char *end = ptr + ctx.value.len; + + while (ptr < end) { + char *name_start, *name_end; + + /* Skip whitespace */ + while (ptr < end && (*ptr == ' ' || *ptr == '\t')) + ptr++; + + if (ptr >= end) + break; + + name_start = ptr; + + /* Find end of cookie name (until '=' or ';') */ + while (ptr < end && *ptr != '=' && *ptr != ';') + ptr++; + + name_end = ptr; + + /* Skip the value */ + if (ptr < end && *ptr == '=') { + ptr++; + while (ptr < end && *ptr != ';') + ptr++; + } + + /* Skip semicolon and whitespace */ + if (ptr < end && *ptr == ';') + ptr++; + + /* Add cookie name if valid */ + if (name_end > name_start) { + if (cookie_names_count >= cookie_names_alloc) { + cookie_names_alloc *= 2; + tmp = realloc(cookie_names, cookie_names_alloc * sizeof(*cookie_names)); + if (!tmp) + goto error; + cookie_names = tmp; + } + cookie_names[cookie_names_count].ptr = name_start; + cookie_names[cookie_names_count].len = name_end - name_start; + cookie_names_count++; + } + } + } + + /* Sort cookie names alphabetically */ + if (cookie_names_count > 0) + qsort(cookie_names, cookie_names_count, sizeof(*cookie_names), ja4h_strcmp); + + /* Build comma-separated sorted cookie names string and hash it */ + cookie_buf = get_trash_chunk(); + if (!cookie_buf) + goto error; + + for (i = 0; i < cookie_names_count; i++) { + if (i > 0) + chunk_appendf(cookie_buf, ","); + chunk_istcat(cookie_buf, cookie_names[i]); + } + + if (cookie_buf->data > 0) { + cookies_hash = XXH3(cookie_buf->area, cookie_buf->data, 0); + ja4h_hash_to_hex(cookies_hash, cookies_hex); + } + } + + /* Build final JA4H string */ + result = get_trash_chunk(); + if (!result) + goto error; + + /* Cap header count at 99 for the 2-digit format */ + if (hdr_count > 99) + hdr_count = 99; + + chunk_appendf(result, "%s%s%c%c%02d_%s_%s_%s", + method_str, version_str, cookie_flag, referer_flag, hdr_count, + accept_lang_hex, headers_hex, cookies_hex); + + free(hdr_names); + free(cookie_names); + + smp->data.type = SMP_T_STR; + smp->data.u.str = *result; + smp->flags = SMP_F_VOL_HDR; + return 1; + +error: + free(hdr_names); + free(cookie_names); + return 0; +} + /************************************************************************/ /* Other utility functions */ /************************************************************************/ @@ -2291,6 +2579,7 @@ static struct sample_fetch_kw_list sample_fetch_keywords = {ILH, { { "http_auth", smp_fetch_http_auth, ARG1(1,USR), NULL, SMP_T_BOOL, SMP_USE_HRQHV }, { "http_auth_group", smp_fetch_http_auth_grp, ARG1(1,USR), NULL, SMP_T_STR, SMP_USE_HRQHV }, { "http_first_req", smp_fetch_http_first_req, 0, NULL, SMP_T_BOOL, SMP_USE_HRQHP }, + { "ja4h", smp_fetch_ja4h, 0, NULL, SMP_T_STR, SMP_USE_HRQHV }, { "method", smp_fetch_meth, 0, NULL, SMP_T_METH, SMP_USE_HRQHP }, { "path", smp_fetch_path, 0, NULL, SMP_T_STR, SMP_USE_HRQHV }, { "pathq", smp_fetch_path, 0, NULL, SMP_T_STR, SMP_USE_HRQHV }, -- 2.43.0

