Skip to content

Commit a7a1dd3

Browse files
committed
Fix preserve logic to check for any header in fingerprint group
Problem: When --preserve was enabled and a request passed through multiple proxies, each header was checked individually. This could result in mismatched fingerprint data - for example, x-ja3-raw being added by a downstream proxy while x-ja3-sig was preserved from an upstream proxy. Solution: The JA3 and JA4 fingerprint plugins now check if ANY header in a fingerprint group exists before adding headers. If any header in the group exists, ALL headers in that group are skipped. Changes: - ja3_fingerprint: Added group-level checks for JA3 headers - ja4_fingerprint: Added --preserve option and group-level check for JA4 headers - Updated tests to verify group-level preserve behavior
1 parent a5363d2 commit a7a1dd3

9 files changed

Lines changed: 240 additions & 32 deletions

File tree

plugins/experimental/ja4_fingerprint/plugin.cc

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@
3131
#include <openssl/ssl.h>
3232

3333
#include <arpa/inet.h>
34+
#include <getopt.h>
3435
#include <netinet/in.h>
3536

3637
#include <cstddef>
3738
#include <cstdint>
3839
#include <cstdio>
40+
#include <cstring>
3941
#include <memory>
4042
#include <string>
4143
#include <string_view>
@@ -79,8 +81,37 @@ constexpr int SSL_SUCCESS{1};
7981

8082
DbgCtl dbg_ctl{PLUGIN_NAME};
8183

84+
int global_preserve_enabled{0};
85+
8286
} // end anonymous namespace
8387

88+
static bool
89+
read_config_option(int argc, char const *argv[], int &preserve)
90+
{
91+
const struct option longopts[] = {
92+
{"preserve", no_argument, &preserve, 1},
93+
{nullptr, 0, nullptr, 0}
94+
};
95+
96+
optind = 0;
97+
int opt{0};
98+
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "", longopts, nullptr)) >= 0) {
99+
switch (opt) {
100+
case '?':
101+
Dbg(dbg_ctl, "Unrecognized command argument.");
102+
case 0:
103+
case -1:
104+
break;
105+
default:
106+
Dbg(dbg_ctl, "Unexpected options error.");
107+
return false;
108+
}
109+
}
110+
111+
Dbg(dbg_ctl, "JA4 preserve is %s", (preserve == 1) ? "enabled" : "disabled");
112+
return true;
113+
}
114+
84115
static int *
85116
get_user_arg_index()
86117
{
@@ -112,12 +143,16 @@ make_word(unsigned char lowbyte, unsigned char highbyte)
112143
}
113144

114145
void
115-
TSPluginInit(int /* argc ATS_UNUSED */, char const ** /* argv ATS_UNUSED */)
146+
TSPluginInit(int argc, char const **argv)
116147
{
117148
if (!register_plugin()) {
118149
TSError("[%s] Failed to register.", PLUGIN_NAME);
119150
return;
120151
}
152+
if (!read_config_option(argc, argv, global_preserve_enabled)) {
153+
TSError("[%s] Failed to parse options.", PLUGIN_NAME);
154+
return;
155+
}
121156
reserve_user_arg();
122157
if (!create_log_file()) {
123158
TSError("[%s] Failed to create log.", PLUGIN_NAME);
@@ -334,12 +369,36 @@ handle_read_request_hdr(TSCont cont, TSEvent event, void *edata)
334369
return TS_SUCCESS;
335370
}
336371

372+
// Check if a header field exists in the request.
373+
static bool
374+
header_exists(TSMBuffer bufp, TSMLoc hdr_loc, char const *field, int field_len)
375+
{
376+
TSMLoc loc = TSMimeHdrFieldFind(bufp, hdr_loc, field, field_len);
377+
if (loc != TS_NULL_MLOC) {
378+
TSHandleMLocRelease(bufp, hdr_loc, loc);
379+
return true;
380+
}
381+
return false;
382+
}
383+
337384
void
338385
append_JA4_headers(TSCont /* cont ATS_UNUSED */, TSHttpTxn txnp, std::string const *fingerprint)
339386
{
340387
TSMBuffer bufp;
341388
TSMLoc hdr_loc;
342-
if (TS_SUCCESS == TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
389+
if (TS_SUCCESS != TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
390+
Dbg(dbg_ctl, "Failed to get headers.");
391+
return;
392+
}
393+
394+
// When preserve is enabled, check if ANY JA4 header exists. If so, skip
395+
// adding ALL JA4 headers to avoid mismatched fingerprint data when requests
396+
// traverse multiple proxies.
397+
bool const ja4_header_exists = header_exists(bufp, hdr_loc, "ja4", 3) ||
398+
header_exists(bufp, hdr_loc, JA4_VIA_HEADER.data(), static_cast<int>(JA4_VIA_HEADER.length()));
399+
bool const skip_ja4_headers = global_preserve_enabled && ja4_header_exists;
400+
401+
if (!skip_ja4_headers) {
343402
append_to_field(bufp, hdr_loc, "ja4", 3, fingerprint->data(), fingerprint->size());
344403

345404
TSMgmtString proxy_name = nullptr;
@@ -351,9 +410,6 @@ append_JA4_headers(TSCont /* cont ATS_UNUSED */, TSHttpTxn txnp, std::string con
351410
append_to_field(bufp, hdr_loc, JA4_VIA_HEADER.data(), static_cast<int>(JA4_VIA_HEADER.length()), proxy_name,
352411
static_cast<int>(std::strlen(proxy_name)));
353412
TSfree(proxy_name);
354-
355-
} else {
356-
Dbg(dbg_ctl, "Failed to get headers.");
357413
}
358414

359415
TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);

plugins/ja3_fingerprint/ja3_fingerprint.cc

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,18 @@ append_to_field(TSMBuffer bufp, TSMLoc hdr_loc, const char *field, int field_len
194194
TSHandleMLocRelease(bufp, hdr_loc, target);
195195
}
196196

197+
// Check if a header field exists in the request.
198+
static bool
199+
header_exists(TSMBuffer bufp, TSMLoc hdr_loc, char const *field, int field_len)
200+
{
201+
TSMLoc loc = TSMimeHdrFieldFind(bufp, hdr_loc, field, field_len);
202+
if (loc != TS_NULL_MLOC) {
203+
TSHandleMLocRelease(bufp, hdr_loc, loc);
204+
return true;
205+
}
206+
return false;
207+
}
208+
197209
static ja3_data *
198210
create_ja3_data(TSVConn const ssl_vc)
199211
{
@@ -258,23 +270,31 @@ modify_ja3_headers(TSCont contp, TSHttpTxn txnp, ja3_data const *ja3_vconn_data)
258270
TSAssert(TS_SUCCESS == TSHttpTxnServerReqGet(txnp, &bufp, &hdr_loc));
259271
}
260272

261-
TSMgmtString proxy_name = nullptr;
262-
if (TS_SUCCESS != TSMgmtStringGet("proxy.config.proxy_name", &proxy_name)) {
263-
TSError("[%s] Failed to get proxy name for %s, set 'proxy.config.proxy_name' in records.config", PLUGIN_NAME,
264-
JA3_VIA_HEADER.data());
265-
proxy_name = TSstrdup("unknown");
266-
}
267-
append_to_field(bufp, hdr_loc, JA3_VIA_HEADER.data(), static_cast<int>(JA3_VIA_HEADER.length()), proxy_name,
268-
static_cast<int>(std::strlen(proxy_name)), preserve_flag);
269-
TSfree(proxy_name);
273+
// When preserve is enabled, check if ANY JA3 header exists. If so, skip
274+
// adding ALL JA3 headers to avoid mismatched fingerprint data when requests
275+
// traverse multiple proxies.
276+
bool const ja3_header_exists = header_exists(bufp, hdr_loc, JA3_VIA_HEADER.data(), static_cast<int>(JA3_VIA_HEADER.length())) ||
277+
header_exists(bufp, hdr_loc, "x-ja3-sig", 9) || header_exists(bufp, hdr_loc, "x-ja3-raw", 9);
278+
bool const skip_ja3_headers = preserve_flag && ja3_header_exists;
279+
280+
if (!skip_ja3_headers) {
281+
TSMgmtString proxy_name = nullptr;
282+
if (TS_SUCCESS != TSMgmtStringGet("proxy.config.proxy_name", &proxy_name)) {
283+
TSError("[%s] Failed to get proxy name for %s, set 'proxy.config.proxy_name' in records.config", PLUGIN_NAME,
284+
JA3_VIA_HEADER.data());
285+
proxy_name = TSstrdup("unknown");
286+
}
287+
append_to_field(bufp, hdr_loc, JA3_VIA_HEADER.data(), static_cast<int>(JA3_VIA_HEADER.length()), proxy_name,
288+
static_cast<int>(std::strlen(proxy_name)), false);
270289

271-
// Add JA3 md5 fingerprints
272-
append_to_field(bufp, hdr_loc, "x-ja3-sig", 9, ja3_vconn_data->md5_string, 32, preserve_flag);
290+
// Add JA3 md5 fingerprints.
291+
append_to_field(bufp, hdr_loc, "x-ja3-sig", 9, ja3_vconn_data->md5_string, 32, false);
273292

274-
// If raw string is configured, added JA3 raw string to header as well
275-
if (raw_flag) {
276-
append_to_field(bufp, hdr_loc, "x-ja3-raw", 9, ja3_vconn_data->ja3_string.data(), ja3_vconn_data->ja3_string.size(),
277-
preserve_flag);
293+
// If raw string is configured, add JA3 raw string to header as well.
294+
if (raw_flag) {
295+
append_to_field(bufp, hdr_loc, "x-ja3-raw", 9, ja3_vconn_data->ja3_string.data(), ja3_vconn_data->ja3_string.size(), false);
296+
}
297+
TSfree(proxy_name);
278298
}
279299
TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
280300

tests/gold_tests/pluginTest/ja3_fingerprint/ja3_fingerprint.test.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,18 @@ def _configure_server(self, tr: 'TestRun') -> None:
8181
self._server.Streams.All += Testers.ContainsExpression("https-request", "Verify the HTTPS request was received.")
8282
self._server.Streams.All += Testers.ContainsExpression("http2-request", "Verify the HTTP/2 request was received.")
8383
if not self._test_remap:
84-
# Verify --preserve worked.
85-
self._server.Streams.All += Testers.ContainsExpression("x-ja3-raw: .*,", "Verify the new raw header was added.")
84+
# The first request has no existing JA3 headers, so headers are added.
8685
self._server.Streams.All += Testers.ContainsExpression(
87-
"x-ja3-raw: first-signature", "Verify the already-existing raw header was preserved.")
88-
self._server.Streams.All += Testers.ExcludesExpression(
89-
"x-ja3-raw: first-signature;", "Verify no extra values were added due to preserve.")
86+
"x-ja3-raw: .*,", "Verify the new raw header was added.", reflags=re.IGNORECASE)
9087
self._server.Streams.All += Testers.ContainsExpression("x-ja3-via: test.proxy.com", "The x-ja3-via string was added.")
88+
# The second request has existing JA3 headers. With --preserve,
89+
# no new JA3 headers are added (including x-ja3-sig).
90+
self._server.Streams.All += Testers.ContainsExpression(
91+
"x-ja3-raw: first-signature", "Verify the already-existing raw header was preserved.", reflags=re.IGNORECASE)
92+
self._server.Streams.All += Testers.ExcludesExpression(
93+
"x-ja3-sig:.*http2-request",
94+
"Verify no JA3-Sig was added when other JA3 headers existed.",
95+
reflags=re.IGNORECASE | re.DOTALL)
9196

9297
def _configure_trafficserver(self) -> None:
9398
"""Configure Traffic Server to be used in the test."""
@@ -189,8 +194,10 @@ def _verify_internal_headers(self) -> None:
189194

190195
if self._modify_incoming:
191196
p.Streams.All += "modify-incoming-proxy.gold"
197+
elif self._test_remap:
198+
p.Streams.All += "modify-sent-proxy-remap.gold"
192199
else:
193-
p.Streams.All += "modify-sent-proxy.gold"
200+
p.Streams.All += "modify-sent-proxy-global.gold"
194201

195202

196203
JA3FingerprintTest(test_remap=False, modify_incoming=False)

tests/gold_tests/pluginTest/ja3_fingerprint/ja3_fingerprint_global.replay.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,14 @@ sessions:
8686
content:
8787
size: 399
8888

89+
# With --preserve and any JA3 header present, no new headers are added.
8990
proxy-request:
9091
headers:
9192
fields:
9293
- [ x-request, { value: 'http2-request', as: equal } ]
9394
- [ x-ja3-via, { value: 'first-via', as: equal } ]
94-
- [ X-JA3-Sig, { as: present } ]
95-
- [ X-JA3-Raw, { as: present } ]
95+
- [ x-ja3-raw, { value: 'first-signature', as: equal } ]
96+
- [ x-ja3-sig, { as: absent } ]
9697

9798
server-response:
9899
headers:

tests/gold_tests/pluginTest/ja3_fingerprint/modify-incoming-proxy.gold

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
-- State Machine Id``
33
POST /some/path/http2``
44
``
5-
x-ja3-sig: ``
5+
x-ja3-raw: first-signature``
6+
x-ja3-via: first-via``
67
``
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
+++++++++ Proxy's Request after hooks +++++++++
2+
-- State Machine Id``
3+
POST /some/path/http2``
4+
``
5+
x-ja3-raw: first-signature``
6+
x-ja3-via: first-via``
7+
``

tests/gold_tests/pluginTest/ja3_fingerprint/modify-sent-proxy.gold renamed to tests/gold_tests/pluginTest/ja3_fingerprint/modify-sent-proxy-remap.gold

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
POST /some/path/http2``
44
``
55
x-ja3-sig: ``
6+
x-ja3-via: ``
67
``

tests/gold_tests/pluginTest/ja4_fingerprint/ja4_fingerprint.replay.yaml

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ meta:
1818
version: "1.0"
1919

2020
sessions:
21+
22+
# Session 1: No pre-existing JA4 headers - new headers should be added.
2123
- protocol:
2224
- name: http
2325
version: 1
@@ -33,11 +35,12 @@ sessions:
3335
fields:
3436
- [ Connection, keep-alive ]
3537
- [ Content-Length, 0 ]
38+
- [ uuid, no-existing-headers ]
3639

3740
proxy-request:
3841
headers:
3942
fields:
40-
- [ ja4, { as: contains } ]
43+
- [ ja4, { as: present } ]
4144
- [ x-ja4-via, { value: 'test.proxy.com', as: equal } ]
4245

4346
server-response:
@@ -46,3 +49,70 @@ sessions:
4649
content:
4750
encoding: plain
4851
data: Yay!
52+
53+
# Session 2: Pre-existing JA4 headers - with preserve, no new headers added.
54+
- protocol:
55+
- name: http
56+
version: 1
57+
- name: tcp
58+
- name: ip
59+
60+
transactions:
61+
- client-request:
62+
method: "GET"
63+
version: "1.1"
64+
url: /resource-with-headers
65+
headers:
66+
fields:
67+
- [ Connection, keep-alive ]
68+
- [ Content-Length, 0 ]
69+
- [ uuid, existing-ja4-headers ]
70+
- [ ja4, upstream-fingerprint ]
71+
- [ x-ja4-via, upstream.proxy.com ]
72+
73+
# With --preserve and existing JA4 headers, no new headers should be added.
74+
proxy-request:
75+
headers:
76+
fields:
77+
- [ ja4, { value: 'upstream-fingerprint', as: equal } ]
78+
- [ x-ja4-via, { value: 'upstream.proxy.com', as: equal } ]
79+
80+
server-response:
81+
status: 200
82+
reason: OK
83+
content:
84+
encoding: plain
85+
data: Preserved!
86+
87+
# Session 3: Only x-ja4-via exists - should still trigger preserve for all.
88+
- protocol:
89+
- name: http
90+
version: 1
91+
- name: tcp
92+
- name: ip
93+
94+
transactions:
95+
- client-request:
96+
method: "GET"
97+
version: "1.1"
98+
url: /resource-via-only
99+
headers:
100+
fields:
101+
- [ Connection, keep-alive ]
102+
- [ Content-Length, 0 ]
103+
- [ uuid, existing-via-only ]
104+
- [ x-ja4-via, upstream.proxy.com ]
105+
106+
# With --preserve and only x-ja4-via present, no JA4 headers should be added.
107+
proxy-request:
108+
headers:
109+
fields:
110+
- [ ja4, { as: absent } ]
111+
- [ x-ja4-via, { value: 'upstream.proxy.com', as: equal } ]
112+
113+
server-response:
114+
status: 200
115+
reason: OK
116+
content:
117+
encoding: plain
118+
data: Via only!

0 commit comments

Comments
 (0)