You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by ma...@apache.org on 2026/04/08 15:40:29 UTC
(trafficserver) branch master updated: Add jax_fingerprint plugin (#12995)
This is an automated email from the ASF dual-hosted git repository.
maskit pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 910f3e53e9 Add jax_fingerprint plugin (#12995)
910f3e53e9 is described below
commit 910f3e53e9e1ad1572d23e95532aae946e7c1e1f
Author: Masakazu Kitajo <ma...@apache.org>
AuthorDate: Wed Apr 8 09:40:22 2026 -0600
Add jax_fingerprint plugin (#12995)
This adds an experimental plugin jax_fingerprint. The basic functionality is the same as ja3_fingerprint and ja4_fingerprint, but the configuration is more flexible.
---
cmake/ExperimentalPlugins.cmake | 1 +
doc/admin-guide/plugins/index.en.rst | 4 +
doc/admin-guide/plugins/jax_fingerprint.en.rst | 182 ++++++++
plugins/experimental/CMakeLists.txt | 3 +
.../experimental/jax_fingerprint/CMakeLists.txt | 54 +++
plugins/experimental/jax_fingerprint/config.h | 70 +++
plugins/experimental/jax_fingerprint/context.cc | 78 ++++
plugins/experimental/jax_fingerprint/context.h | 48 +++
plugins/experimental/jax_fingerprint/header.cc | 162 +++++++
plugins/experimental/jax_fingerprint/header.h | 34 ++
.../experimental/jax_fingerprint/ja3/ja3_method.cc | 128 ++++++
.../experimental/jax_fingerprint/ja3/ja3_method.h | 32 ++
.../experimental/jax_fingerprint/ja3/ja3_utils.cc | 108 +++++
.../experimental/jax_fingerprint/ja3/ja3_utils.h | 73 ++++
.../experimental/jax_fingerprint/ja3/test_ja3.cc | 96 +++++
plugins/experimental/jax_fingerprint/ja4/ja4.cc | 175 ++++++++
plugins/experimental/jax_fingerprint/ja4/ja4.h | 171 ++++++++
.../experimental/jax_fingerprint/ja4/ja4_method.cc | 159 +++++++
.../experimental/jax_fingerprint/ja4/ja4_method.h | 32 ++
.../experimental/jax_fingerprint/ja4/test_ja4.cc | 425 ++++++++++++++++++
.../ja4/tls_client_hello_summary.cc | 112 +++++
.../jax_fingerprint/ja4h/datasource.cc | 44 ++
.../experimental/jax_fingerprint/ja4h/datasource.h | 42 ++
plugins/experimental/jax_fingerprint/ja4h/ja4h.cc | 118 +++++
plugins/experimental/jax_fingerprint/ja4h/ja4h.h | 41 ++
.../jax_fingerprint/ja4h/ja4h_method.cc | 173 ++++++++
.../jax_fingerprint/ja4h/ja4h_method.h | 32 ++
.../experimental/jax_fingerprint/ja4h/test_ja4h.cc | 168 ++++++++
plugins/experimental/jax_fingerprint/log.cc | 52 +++
plugins/experimental/jax_fingerprint/log.h | 30 ++
plugins/experimental/jax_fingerprint/method.h | 68 +++
plugins/experimental/jax_fingerprint/plugin.cc | 480 +++++++++++++++++++++
plugins/experimental/jax_fingerprint/plugin.h | 31 ++
plugins/experimental/jax_fingerprint/userarg.cc | 53 +++
plugins/experimental/jax_fingerprint/userarg.h | 32 ++
.../jax_fingerprint/jax_fingerprint.test.py | 339 +++++++++++++++
.../jax_fingerprint_append.replay.yaml | 114 +++++
.../jax_fingerprint_global.replay.yaml | 111 +++++
.../jax_fingerprint_global_h2.replay.yaml | 121 ++++++
.../jax_fingerprint_hybrid_servernames.replay.yaml | 124 ++++++
.../jax_fingerprint_keep.replay.yaml | 111 +++++
.../jax_fingerprint_remap.replay.yaml | 109 +++++
.../jax_fingerprint_remap_tls.replay.yaml | 120 ++++++
.../jax_fingerprint_servernames.replay.yaml | 114 +++++
.../jax_fingerprint_tls.replay.yaml | 111 +++++
45 files changed, 4885 insertions(+)
diff --git a/cmake/ExperimentalPlugins.cmake b/cmake/ExperimentalPlugins.cmake
index 764571546c..5e73ffa5ec 100644
--- a/cmake/ExperimentalPlugins.cmake
+++ b/cmake/ExperimentalPlugins.cmake
@@ -43,6 +43,7 @@ auto_option(HTTP_STATS FEATURE_VAR BUILD_HTTP_STATS DEFAULT ${_DEFAULT})
auto_option(ICAP FEATURE_VAR BUILD_ICAP DEFAULT ${_DEFAULT})
auto_option(INLINER FEATURE_VAR BUILD_INLINER DEFAULT ${_DEFAULT})
auto_option(JA4_FINGERPRINT FEATURE_VAR BUILD_JA4_FINGERPRINT VAR_DEPENDS DEFAULT ${_DEFAULT})
+auto_option(JAX_FINGERPRINT FEATURE_VAR BUILD_JAX_FINGERPRINT VAR_DEPENDS DEFAULT ${_DEFAULT})
auto_option(
MAGICK
FEATURE_VAR
diff --git a/doc/admin-guide/plugins/index.en.rst b/doc/admin-guide/plugins/index.en.rst
index 5379c9a72e..c2847b3237 100644
--- a/doc/admin-guide/plugins/index.en.rst
+++ b/doc/admin-guide/plugins/index.en.rst
@@ -179,6 +179,7 @@ directory of the |TS| source tree. Experimental plugins can be compiled by passi
Hook Trace <hook-trace.en>
ICAP <icap.en>
JA4 Fingerprint <ja4_fingerprint.en>
+ JAx Fingerprint <jax_fingerprint.en>
Maxmind ACL <maxmind_acl.en>
Memcache <memcache.en>
Memory Profile <memory_profile.en>
@@ -236,6 +237,9 @@ directory of the |TS| source tree. Experimental plugins can be compiled by passi
:doc:`JA4 Fingerprint <ja4_fingerprint.en>`
Calculates JA4 Fingerprints for incoming TLS traffic.
+:doc:`JAx Fingerprint <jax_fingerprint.en>`
+ Calculates JAx Fingerprints.
+
:doc:`MaxMind ACL <maxmind_acl.en>`
ACL based on the maxmind geo databases (GeoIP2 mmdb and libmaxminddb)
diff --git a/doc/admin-guide/plugins/jax_fingerprint.en.rst b/doc/admin-guide/plugins/jax_fingerprint.en.rst
new file mode 100644
index 0000000000..e500cba6a2
--- /dev/null
+++ b/doc/admin-guide/plugins/jax_fingerprint.en.rst
@@ -0,0 +1,182 @@
+.. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+.. include:: ../../common.defs
+
+.. _admin-plugins-jax-fingerprint:
+
+JAx Fingerprint Plugin
+**********************
+
+Description
+===========
+
+The JAx Fingerprint plugin generates client fingerprints based on the JA4+ or JA3 algorithms designed by John Althouse.
+
+Fingerprints can be used for:
+
+* Client identification and tracking
+* Bot detection and mitigation
+* Security analytics and threat intelligence
+* Understanding client implementation patterns
+
+
+Plugin Configuration
+====================
+
+You can use the plugin as a global plugin, a remap plugin, or both.
+
+To use the plugin as a global plugin, add the following line to :file:`plugin.config`::
+
+ jax_fingerprint.so --standalone
+
+To use the plugin as a remap plugin, append the following line to a remap rule on :file:`remap.config`::
+
+ @plugin=jax_fingerprint.so @pparam=--standalone
+
+To use the plugin in a hybrid setup (both global and remap plugin), configure it in both :file:`plugin.config` and
+:file:`remap.config` without ``--standalone`` option.
+
+
+.. option:: --standalone
+
+This option enables you to use the plugin as either a global plugin, or a remap plugin. In other
+words, the option needs to be specified if you do not use the hybrid setup.
+
+.. option:: --method <JA4|JA4H|JA3>
+
+Fingerprinting method (e.g. JA4, JA3, etc.) to use. This option must be specified.
+
+.. option:: --mode <overwrite|keep|append>
+
+This option specifies what to do if requests from clients have the header names that are specified
+by `--header` and/or `--via-header`. Available setting values are "overwrite", "keep" and "append".
+The default mode is "overwrite".
+
+.. option:: --servernames <servername1,servername2,...>
+
+This option specifies server name(s) for which the plugin generates fingerprints.
+The value must be provided as a single comma separated value (no space) of server names.
+If the option is not specified, the plugin generates fingerprints for any server names.
+
+.. option:: --header <header_name>
+
+This option specifies the name of the header field where the plugin stores the generated fingerprint value. If not specified, header generation will be suppressed.
+
+.. option:: --via-header <via_header_name>
+
+This option specifies the name of the header field where the plugin stores the generated fingerprint-via value. If not specified, header generation will be suppressed.
+
+.. option:: --log-filename <filename>
+
+This option specifies the filename for the plugin log file. If not specified, log output will be suppressed.
+
+
+Plugin Behavior
+===============
+
+Global plugin setup
+-------------------
+
+Global plugin setup is the best if you:
+ * Need a fingerprint on every request
+
+Remap plugin setup
+------------------
+
+Remap plugin setup is the best if you:
+ * Need a fingerprint only on specific paths, or
+ * Cannot use Global plugin setup
+
+.. note:: For JA3 and JA4, fingerprints are always generated at the beginning of connections. Using remap plugin setup only reduces the overhead of adding HTTP headers and logging.
+
+Hybrid setup
+------------
+
+Hybrid setup is the best if you:
+ * Need a fingerprint only for specific server names (in TLS SNI extension), and
+ * Need a fingerprint only on specific paths
+
+
+Log Output
+==========
+
+The plugin outputs a log file in the Traffic Server log directory (typically ``/var/log/trafficserver/``) if a log filename is
+specified by ``--log-filename`` option.
+
+**Log Format**::
+
+ [timestamp] Client: <address> <method_name>: <fingerprint>
+
+**Example**::
+
+ [Jan 29 10:15:23.456] Client: 192.168.1.100 JA4: t13d1516h2_8daaf6152771_b186095e22b6
+ [Jan 29 10:15:24.123] Client: 10.0.0.50 JA4: t13d1715h2_8daaf6152771_02713d6af862
+
+
+Using HTTP Headers in Origin Requests
+=====================================
+
+Origin servers can access the generated fingerprint through the injected HTTP header.
+This allows the origin to:
+
+* Make access control decisions based on client fingerprints
+* Log fingerprints for security analysis
+* Track client populations and TLS implementation patterns
+
+The fingerprint-via header allows origin servers to track which Traffic Server proxy handled the request when multiple proxies are deployed.
+
+
+Debugging
+=========
+
+To enable debug logging for the plugin, set the following in :file:`records.yaml`::
+
+ records:
+ diags:
+ debug:
+ enabled: 1
+ tags: jax_fingerprint
+
+
+Requirements
+============
+
+* Traffic Server must be built with TLS support (OpenSSL or BoringSSL) if you use JA3 or JA4
+
+
+See Also
+========
+* JA3 Technical Specification: https://github.com/FoxIO-LLC/ja3
+* JA4+ Technical Specification: https://github.com/FoxIO-LLC/ja4
+
+
+Example Configuration
+=====================
+
+Enable JA4 fingerprinting by hybrid (global + remap) setup
+----------------------------------------------------------
+
+This configuration adds x-my-ja4 header if a connection is established for either abc.example or xyz.example.
+
+**plugin.config**::
+
+ jax_fingerprint.so --method JA4 --servernames abc.example,xyz.example
+
+**remap.config**::
+
+ map / http://origin.example/ @plugin=jax_fingerprint.so @pparam=--method=JA4 @pparam=--header=x-my-ja4
diff --git a/plugins/experimental/CMakeLists.txt b/plugins/experimental/CMakeLists.txt
index 8d63d3356d..fe7897c585 100644
--- a/plugins/experimental/CMakeLists.txt
+++ b/plugins/experimental/CMakeLists.txt
@@ -68,6 +68,9 @@ endif()
if(BUILD_JA4_FINGERPRINT)
add_subdirectory(ja4_fingerprint)
endif()
+if(BUILD_JAX_FINGERPRINT)
+ add_subdirectory(jax_fingerprint)
+endif()
if(BUILD_MAGICK)
add_subdirectory(magick)
endif()
diff --git a/plugins/experimental/jax_fingerprint/CMakeLists.txt b/plugins/experimental/jax_fingerprint/CMakeLists.txt
new file mode 100644
index 0000000000..173eb5750b
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/CMakeLists.txt
@@ -0,0 +1,54 @@
+#######################
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+# agreements. See the NOTICE file distributed with this work for additional information regarding
+# copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License
+# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+# or implied. See the License for the specific language governing permissions and limitations under
+# the License.
+#
+#######################
+
+add_atsplugin(
+ jax_fingerprint
+ plugin.cc
+ context.cc
+ userarg.cc
+ header.cc
+ log.cc
+ ja3/ja3_method.cc
+ ja3/ja3_utils.cc
+ ja4/ja4_method.cc
+ ja4/ja4.cc
+ ja4/tls_client_hello_summary.cc
+ ja4h/ja4h_method.cc
+ ja4h/ja4h.cc
+ ja4h/datasource.cc
+)
+target_link_libraries(jax_fingerprint PRIVATE OpenSSL::Crypto OpenSSL::SSL)
+verify_global_plugin(jax_fingerprint)
+target_include_directories(jax_fingerprint BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+
+if(BUILD_TESTING)
+ add_executable(
+ test_jax
+ ja3/test_ja3.cc
+ ja3/ja3_utils.cc
+ ja4/test_ja4.cc
+ ja4/ja4.cc
+ ja4/tls_client_hello_summary.cc
+ ja4h/test_ja4h.cc
+ ja4h/ja4h.cc
+ ja4h/datasource.cc
+ )
+ target_link_libraries(test_jax PRIVATE Catch2::Catch2WithMain OpenSSL::Crypto OpenSSL::SSL)
+ target_include_directories(test_jax BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+
+ add_catch2_test(NAME test_jax COMMAND test_jax)
+endif()
diff --git a/plugins/experimental/jax_fingerprint/config.h b/plugins/experimental/jax_fingerprint/config.h
new file mode 100644
index 0000000000..3d49894bff
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/config.h
@@ -0,0 +1,70 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+#include "plugin.h"
+#include "method.h"
+
+#include <string>
+#include <unordered_set>
+
+enum class Mode : int {
+ OVERWRITE,
+ KEEP,
+ APPEND,
+};
+
+enum class PluginType : int {
+ GLOBAL,
+ REMAP,
+};
+
+// This hash function enables looking up the set by a string_view without making a temporary string object.
+struct StringHash {
+ // Enable heterogeneous lookup
+ using is_transparent = void;
+
+ size_t
+ operator()(std::string_view sv) const
+ {
+ return std::hash<std::string_view>{}(sv);
+ }
+};
+
+struct PluginConfig {
+ PluginConfig() { Dbg(dbg_ctl, "New config (%p) has been created", this); }
+ ~PluginConfig() { Dbg(dbg_ctl, "Config for (%p) has been deleted", this); }
+
+ PluginType plugin_type = PluginType::GLOBAL;
+ Mode mode = Mode::OVERWRITE;
+ struct Method method = {"uninitialized", Method::Type::CONNECTION_BASED, nullptr, nullptr};
+ std::string header_name = "";
+ std::string via_header_name = "";
+ std::string log_filename = "";
+ int user_arg_index = -1;
+ TSCont handler = nullptr; // For remap plugin
+ bool standalone = false;
+ std::unordered_set<std::string, StringHash, std::equal_to<>> servernames;
+ TSTextLogObject log_handle = nullptr;
+};
diff --git a/plugins/experimental/jax_fingerprint/context.cc b/plugins/experimental/jax_fingerprint/context.cc
new file mode 100644
index 0000000000..f9f6b06a1c
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/context.cc
@@ -0,0 +1,78 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "plugin.h"
+#include "context.h"
+
+JAxContext::JAxContext(const char *method_name, sockaddr const *s_sockaddr) : _method_name(method_name)
+{
+ _addr[0] = '\0';
+
+ if (s_sockaddr == nullptr) {
+ return;
+ }
+
+ switch (s_sockaddr->sa_family) {
+ case AF_INET:
+ inet_ntop(AF_INET, &reinterpret_cast<const sockaddr_in *>(s_sockaddr)->sin_addr, _addr, INET_ADDRSTRLEN);
+ break;
+ case AF_INET6:
+ inet_ntop(AF_INET6, &reinterpret_cast<const sockaddr_in6 *>(s_sockaddr)->sin6_addr, _addr, INET6_ADDRSTRLEN);
+ break;
+ case AF_UNIX:
+ strncpy(_addr, reinterpret_cast<const sockaddr_un *>(s_sockaddr)->sun_path, sizeof(_addr) - 1);
+ _addr[sizeof(_addr) - 1] = '\0';
+ break;
+ default:
+ break;
+ }
+ Dbg(dbg_ctl, "New context for %s (%p) has been created", this->_method_name, this);
+}
+
+JAxContext::~JAxContext()
+{
+ Dbg(dbg_ctl, "Context for %s (%p) has been deleted", this->_method_name, this);
+}
+
+const std::string &
+JAxContext::get_fingerprint() const
+{
+ return this->_fingerprint;
+}
+
+void
+JAxContext::set_fingerprint(const std::string &fingerprint)
+{
+ this->_fingerprint = fingerprint;
+ Dbg(dbg_ctl, "Fingerprint: %s", this->_fingerprint.c_str());
+}
+
+const char *
+JAxContext::get_addr() const
+{
+ return this->_addr;
+}
+
+const char *
+JAxContext::get_method_name() const
+{
+ return this->_method_name;
+}
diff --git a/plugins/experimental/jax_fingerprint/context.h b/plugins/experimental/jax_fingerprint/context.h
new file mode 100644
index 0000000000..2d3bdaff6e
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/context.h
@@ -0,0 +1,48 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+#include <sys/un.h>
+#include <limits.h>
+
+#include <string>
+
+class JAxContext
+{
+public:
+ JAxContext(const char *name, sockaddr const *s_sockaddr);
+ ~JAxContext();
+
+ const std::string &get_fingerprint() const;
+ void set_fingerprint(const std::string &fingerprint);
+
+ const char *get_addr() const;
+ const char *get_method_name() const;
+
+private:
+ std::string _fingerprint;
+ char _addr[PATH_MAX + 1];
+ const char *_method_name;
+};
diff --git a/plugins/experimental/jax_fingerprint/header.cc b/plugins/experimental/jax_fingerprint/header.cc
new file mode 100644
index 0000000000..cb4debe5a3
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/header.cc
@@ -0,0 +1,162 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "plugin.h"
+#include "header.h"
+
+#include "ts/ts.h"
+
+#include <string>
+#include <string_view>
+
+static void
+put_header(TSHttpTxn txnp, const std::string &name, const std::string &value, bool overwrite)
+{
+ TSMBuffer bufp;
+ TSMLoc hdr_loc;
+ if (TS_SUCCESS != TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
+ Dbg(dbg_ctl, "Failed to get headers.");
+ return;
+ }
+
+ TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, name.c_str(), name.length());
+ if (target == TS_NULL_MLOC) {
+ // Add - Create a new field with the value
+ Dbg(dbg_ctl, "Add %s: %s", name.c_str(), value.c_str());
+ TSMimeHdrFieldCreateNamed(bufp, hdr_loc, name.c_str(), name.length(), &target);
+ TSMimeHdrFieldValueStringSet(bufp, hdr_loc, target, -1, value.c_str(), value.length());
+ TSMimeHdrFieldAppend(bufp, hdr_loc, target);
+ TSHandleMLocRelease(bufp, hdr_loc, target);
+ } else if (overwrite) {
+ // Replace - Set the value to the first field and remove all duplicate fields
+ Dbg(dbg_ctl, "Replace %s field value with %s", name.c_str(), value.c_str());
+ TSMLoc tmp = nullptr;
+ bool first = true;
+ while (target) {
+ tmp = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+ if (first) {
+ first = false;
+ TSMimeHdrFieldValueStringSet(bufp, hdr_loc, target, -1, value.c_str(), value.size());
+ } else {
+ TSMimeHdrFieldDestroy(bufp, hdr_loc, target);
+ }
+ TSHandleMLocRelease(bufp, hdr_loc, target);
+ target = tmp;
+ }
+ } else {
+ // Append - Find the last duplicate field and set the value to it
+ Dbg(dbg_ctl, "Append %s to %s field value", value.c_str(), name.c_str());
+ TSMLoc dup = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+ while (dup != TS_NULL_MLOC) {
+ TSHandleMLocRelease(bufp, hdr_loc, target);
+ target = dup;
+ dup = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+ }
+ TSMimeHdrFieldValueStringInsert(bufp, hdr_loc, target, -1, value.c_str(), value.length());
+ TSHandleMLocRelease(bufp, hdr_loc, target);
+ }
+
+ TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+}
+
+static void
+put_via_header(TSHttpTxn txnp, const std::string &via_header, bool overwrite)
+{
+ TSMgmtString proxy_name = nullptr;
+ if (TS_SUCCESS == TSMgmtStringGet("proxy.config.proxy_name", &proxy_name)) {
+ put_header(txnp, via_header, proxy_name, overwrite);
+ TSfree(proxy_name);
+ } else {
+ TSError("[%s] Failed to get proxy name for %s, set 'proxy.config.proxy_name' in records.config", PLUGIN_NAME,
+ via_header.c_str());
+ put_header(txnp, via_header, "unknown", overwrite);
+ }
+}
+
+void
+set_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint)
+{
+ put_header(txnp, header, fingerprint, true);
+}
+
+void
+append_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint)
+{
+ put_header(txnp, header, fingerprint, false);
+}
+
+void
+set_via_header(TSHttpTxn txnp, const std::string &via_header)
+{
+ put_via_header(txnp, via_header, true);
+}
+
+void
+append_via_header(TSHttpTxn txnp, const std::string &via_header)
+{
+ put_via_header(txnp, via_header, false);
+}
+
+void
+remove_header(TSHttpTxn txnp, const std::string &header)
+{
+ TSMBuffer bufp;
+ TSMLoc hdr_loc;
+ if (TS_SUCCESS != TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
+ Dbg(dbg_ctl, "Failed to get headers.");
+ return;
+ }
+
+ TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, header.c_str(), header.length());
+ if (target != TS_NULL_MLOC) {
+ // Remove all
+ Dbg(dbg_ctl, "Remove all %s field", header.c_str());
+ TSMLoc tmp = nullptr;
+ while (target) {
+ tmp = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+ TSMimeHdrFieldDestroy(bufp, hdr_loc, target);
+ TSHandleMLocRelease(bufp, hdr_loc, target);
+ target = tmp;
+ }
+ }
+ TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+}
+
+bool
+has_header(TSHttpTxn txnp, const std::string &header)
+{
+ TSMBuffer bufp;
+ TSMLoc hdr_loc;
+ if (TS_SUCCESS != TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
+ Dbg(dbg_ctl, "Failed to get headers.");
+ return false;
+ }
+
+ TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, header.c_str(), header.length());
+ if (target == TS_NULL_MLOC) {
+ TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+ return false;
+ } else {
+ TSHandleMLocRelease(bufp, hdr_loc, target);
+ TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+ return true;
+ }
+}
diff --git a/plugins/experimental/jax_fingerprint/header.h b/plugins/experimental/jax_fingerprint/header.h
new file mode 100644
index 0000000000..4fe8c0c19a
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/header.h
@@ -0,0 +1,34 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+
+#include <string>
+
+void append_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint);
+void append_via_header(TSHttpTxn txnp, const std::string &via_header);
+void set_header(TSHttpTxn txnp, const std::string &header, const std::string &fingerprint);
+void set_via_header(TSHttpTxn txnp, const std::string &via_header);
+void remove_header(TSHttpTxn txnp, const std::string &header);
+bool has_header(TSHttpTxn txnp, const std::string &header);
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_method.cc b/plugins/experimental/jax_fingerprint/ja3/ja3_method.cc
new file mode 100644
index 0000000000..ef2e0a2160
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_method.cc
@@ -0,0 +1,128 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include "ts/ts.h"
+
+#include <plugin.h>
+#include <context.h>
+#include "ja3_method.h"
+#include "ja3_utils.h"
+
+#include <openssl/ssl.h>
+#include <openssl/md5.h>
+#include <openssl/opensslv.h>
+
+#include <algorithm>
+
+namespace ja3_method
+{
+
+void on_client_hello(JAxContext *, TSVConn);
+
+struct Method method = {
+ "JA3",
+ Method::Type::CONNECTION_BASED,
+ on_client_hello,
+ nullptr,
+};
+
+} // namespace ja3_method
+
+namespace
+{
+constexpr int ja3_hash_included_byte_count{16};
+static_assert(ja3_hash_included_byte_count <= MD5_DIGEST_LENGTH);
+
+constexpr int ja3_hash_hex_string_with_null_terminator_length{2 * ja3_hash_included_byte_count + 1};
+
+} // end anonymous namespace
+
+static std::string
+get_fingerprint(TSClientHello ch)
+{
+ std::string raw;
+ std::size_t len{};
+ const unsigned char *buf{};
+
+ // Get version
+ unsigned int version = ch.get_version();
+ raw.append(std::to_string(version));
+ raw.push_back(',');
+
+ // Get cipher suites
+ raw.append(ja3::encode_word_buffer(ch.get_cipher_suites(), ch.get_cipher_suites_len()));
+ raw.push_back(',');
+
+ // Get extensions
+ auto ext_types = ch.get_extension_types();
+ len = 0;
+ auto first = ext_types.begin();
+ auto last = ext_types.end();
+ while (first != last) {
+ ++first;
+ ++len;
+ }
+ if (len > 0) {
+ int extension_ids[len];
+ first = ext_types.begin();
+ for (size_t i = 0; i < len; ++i, ++first) {
+ extension_ids[i] = *first;
+ }
+ raw.append(ja3::encode_integer_buffer(extension_ids, len));
+ }
+ raw.push_back(',');
+
+ // Get elliptic curves
+ if (TS_SUCCESS == TSClientHelloExtensionGet(ch, 0x0a, &buf, &len) && len >= 2) {
+ // Skip first 2 bytes since we already have length
+ raw.append(ja3::encode_word_buffer(buf + 2, len - 2));
+ }
+ raw.push_back(',');
+
+ // Get elliptic curve point formats
+ if (TS_SUCCESS == TSClientHelloExtensionGet(ch, 0x0b, &buf, &len) && len >= 2) {
+ // Skip first byte since we already have length
+ raw.append(ja3::encode_byte_buffer(buf + 1, len - 1));
+ }
+ Dbg(dbg_ctl, "Hashing %s", raw.c_str());
+
+ char fingerprint[ja3_hash_hex_string_with_null_terminator_length];
+ unsigned char digest[MD5_DIGEST_LENGTH];
+ MD5(reinterpret_cast<unsigned char const *>(raw.c_str()), raw.length(), digest);
+ for (int i{0}; i < ja3_hash_included_byte_count; ++i) {
+ std::snprintf(&(fingerprint[i * 2]), sizeof(fingerprint) - (i * 2), "%02x", static_cast<unsigned int>(digest[i]));
+ }
+
+ return {fingerprint};
+}
+
+void
+ja3_method::on_client_hello(JAxContext *ctx, TSVConn vconn)
+{
+ TSClientHello ch = TSVConnClientHelloGet(vconn);
+
+ if (!ch) {
+ Dbg(dbg_ctl, "Could not get TSClientHello object.");
+ } else {
+ ctx->set_fingerprint(get_fingerprint(ch));
+ }
+}
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_method.h b/plugins/experimental/jax_fingerprint/ja3/ja3_method.h
new file mode 100644
index 0000000000..0797113341
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_method.h
@@ -0,0 +1,32 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include <method.h>
+
+namespace ja3_method
+{
+
+extern struct Method method;
+
+}
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_utils.cc b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.cc
new file mode 100644
index 0000000000..b54e5066c8
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.cc
@@ -0,0 +1,108 @@
+/** @file ja3_utils.cc
+
+ Plugin JA3 Fingerprint calculates JA3 signatures for incoming SSL traffic.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <unordered_set>
+
+namespace ja3
+{
+
+// GREASE table as in ja3
+static std::unordered_set<std::uint16_t> const GREASE_table = {0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a,
+ 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa};
+
+static constexpr std::uint16_t
+from_big_endian(unsigned char lowbyte, unsigned char highbyte)
+{
+ return (static_cast<std::uint16_t>(lowbyte) << 8) | highbyte;
+}
+
+static bool
+ja3_should_ignore(std::uint16_t n)
+{
+ return GREASE_table.find(n) != GREASE_table.end();
+}
+
+std::string
+encode_byte_buffer(unsigned char const *buf, int const len)
+{
+ std::string result;
+ if (len > 0) {
+ // Benchmarks show that reserving space in the string here would cause
+ // a 40% increase in runtime for a buffer with 10 elements... so we
+ // don't do it.
+ result.append(std::to_string(buf[0]));
+ std::for_each(buf + 1, buf + len, [&result](unsigned char i) {
+ result.push_back('-');
+ result.append(std::to_string(i));
+ });
+ }
+ return result;
+}
+
+std::string
+encode_word_buffer(unsigned char const *buf, int const len)
+{
+ std::string result;
+ auto it{buf};
+ while ((it < (buf + len)) && ja3_should_ignore(from_big_endian(it[0], it[1]))) {
+ it += 2;
+ }
+ if (it < (buf + len)) {
+ // Benchmarks show that reserving buf.size() - 1 space in the string here
+ // would have no impact on performance. Since the string may not even need
+ // that much due to GREASE values present in the buffer, we don't do it.
+ result.append(std::to_string(from_big_endian(it[0], it[1])));
+ it += 2;
+ for (; it < buf + len; it += 2) {
+ auto const value{from_big_endian(it[0], it[1])};
+ if (!ja3_should_ignore(value)) {
+ result.push_back('-');
+ result.append(std::to_string(value));
+ }
+ }
+ }
+ return result;
+}
+
+std::string
+encode_integer_buffer(int const *buf, int const len)
+{
+ std::string result;
+ auto it{std::find_if(buf, buf + len, [](int i) { return !ja3_should_ignore(i); })};
+ if (it < (buf + len)) {
+ result.append(std::to_string(*it));
+ std::for_each(it + 1, buf + len, [&result](int const i) {
+ if (!ja3_should_ignore(i)) {
+ result.push_back('-');
+ result.append(std::to_string(i));
+ }
+ });
+ }
+ return result;
+}
+
+} // end namespace ja3
diff --git a/plugins/experimental/jax_fingerprint/ja3/ja3_utils.h b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.h
new file mode 100644
index 0000000000..3a858d4b26
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/ja3_utils.h
@@ -0,0 +1,73 @@
+/** @file ja3_utils.h
+
+ Plugin JA3 Fingerprint calculates JA3 signatures for incoming SSL traffic.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include <string>
+
+namespace ja3
+{
+
+/** Encode a buffer of 8bit values.
+ *
+ * The values will be converted to their decimal string representations and
+ * joined with the '-' character.
+ *
+ * @param buf The buffer to encode. This should be an SSL buffer of 8bit
+ * values.
+ * @param len The length of the buffer. If the length is zero, buf will
+ * not be dereferenced.
+ * @return The string-encoded ja3 representation of the buffer.
+ */
+std::string encode_byte_buffer(unsigned char const *buf, int const len);
+
+/** Encode a buffer of big-endian 16bit values.
+ *
+ * The values will be converted to their decimal string representations and
+ * joined with the '-' character. Any GREASE values in the buffer will be
+ * ignored.
+ *
+ * @param buf The buffer to encode. This should be a big-endian SSL buffer
+ * of 16bit values.
+ * @param len The length of the buffer. If the length is zero, buf will not
+ * be dereferenced.
+ * @return The string-encoded ja3 representation of the buffer.
+ */
+std::string encode_word_buffer(unsigned char const *buf, int const len);
+
+/** Encode a buffer of integers.
+ *
+ * The values will be converted to their decimal string representations and
+ * joined with the '-' character. Any GREASE values in the buffer will be
+ * ignored.
+ *
+ * @param buf The buffer to encode. The buffer underlying the span should be
+ * an SSL buffer of ints.
+ * @param len The length (number of values) in the buffer. If the length is
+ * zero, buf will not be dereferenced.
+ * @return The string-encoded ja3 representation of the buffer.
+ */
+std::string encode_integer_buffer(int const *buf, int const len);
+
+} // end namespace ja3
diff --git a/plugins/experimental/jax_fingerprint/ja3/test_ja3.cc b/plugins/experimental/jax_fingerprint/ja3/test_ja3.cc
new file mode 100644
index 0000000000..1e5de5e25d
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja3/test_ja3.cc
@@ -0,0 +1,96 @@
+/** @file test_utils.cc
+
+ Unit tests for ja3.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include "ja3_utils.h"
+
+#include <catch2/catch_test_macros.hpp>
+
+TEST_CASE("ja3 byte buffer encoding")
+{
+ unsigned char const buf[]{0x8, 0x3, 0x4};
+
+ SECTION("empty buffer")
+ {
+ auto got{ja3::encode_byte_buffer(nullptr, 0)};
+ CHECK("" == got);
+ }
+
+ SECTION("1 value")
+ {
+ auto got{ja3::encode_byte_buffer(buf, 1)};
+ CHECK("8" == got);
+ }
+
+ SECTION("3 values")
+ {
+ auto got{ja3::encode_byte_buffer(buf, 3)};
+ CHECK("8-3-4" == got);
+ }
+}
+
+TEST_CASE("ja3 word buffer encoding")
+{
+ unsigned char const buf[]{0x0, 0x5, 0x0a, 0x0a, 0x0, 0x8, 0xda, 0xda, 0x1, 0x0};
+
+ SECTION("empty buffer")
+ {
+ auto got{ja3::encode_word_buffer(nullptr, 0)};
+ CHECK("" == got);
+ }
+
+ SECTION("1 value")
+ {
+ auto got{ja3::encode_word_buffer(buf, 2)};
+ CHECK("5" == got);
+ }
+
+ SECTION("5 values including GREASE values")
+ {
+ auto got{ja3::encode_word_buffer(buf, 10)};
+ CHECK("5-8-256" == got);
+ }
+}
+
+TEST_CASE("ja3 integer buffer encoding")
+{
+ int const buf[]{5, 2570, 8, 56026, 256};
+
+ SECTION("empty buffer")
+ {
+ auto got{ja3::encode_integer_buffer(nullptr, 0)};
+ CHECK("" == got);
+ }
+
+ SECTION("1 value")
+ {
+ auto got{ja3::encode_integer_buffer(buf, 1)};
+ CHECK("5" == got);
+ }
+
+ SECTION("5 values including GREASE values")
+ {
+ auto got{ja3::encode_integer_buffer(buf, 5)};
+ CHECK("5-8-256" == got);
+ }
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4.cc b/plugins/experimental/jax_fingerprint/ja4/ja4.cc
new file mode 100644
index 0000000000..62a6c08fa4
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4.cc
@@ -0,0 +1,175 @@
+/** @file
+ *
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include "ja4.h"
+
+#include <algorithm>
+#include <cctype>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <iterator>
+#include <string>
+#include <string_view>
+
+static char convert_protocol_to_char(JA4::Protocol protocol);
+static std::string convert_TLS_version_to_string(std::uint16_t TLS_version);
+static char convert_SNI_to_char(JA4::SNI SNI_type);
+static std::string convert_count_to_two_digit_string(std::size_t count);
+static std::string convert_ALPN_to_two_char_string(std::string_view ALPN);
+static void remove_trailing_character(std::string &s);
+static std::string hexify(std::uint16_t n);
+
+namespace
+{
+constexpr std::size_t U16_HEX_BUF_SIZE{4};
+} // end anonymous namespace
+
+std::string
+JA4::make_JA4_a_raw(TLSClientHelloSummary const &TLS_summary)
+{
+ std::string result;
+ result.reserve(9);
+ result.push_back(convert_protocol_to_char(TLS_summary.protocol));
+ result.append(convert_TLS_version_to_string(TLS_summary.TLS_version));
+ result.push_back(convert_SNI_to_char(TLS_summary.get_SNI_type()));
+ result.append(convert_count_to_two_digit_string(TLS_summary.get_cipher_count()));
+ result.append(convert_count_to_two_digit_string(TLS_summary.get_extension_count()));
+ result.append(convert_ALPN_to_two_char_string(TLS_summary.ALPN));
+ return result;
+}
+
+static char
+convert_protocol_to_char(JA4::Protocol protocol)
+{
+ return static_cast<char>(protocol);
+}
+
+static std::string
+convert_TLS_version_to_string(std::uint16_t TLS_version)
+{
+ switch (TLS_version) {
+ case 0x304:
+ return "13";
+ case 0x303:
+ return "12";
+ case 0x302:
+ return "11";
+ case 0x301:
+ return "10";
+ case 0x300:
+ return "s3";
+ case 0x200:
+ return "s2";
+ case 0x100:
+ return "s1";
+ case 0xfeff:
+ return "d1";
+ case 0xfefd:
+ return "d2";
+ case 0xfefc:
+ return "d3";
+ default:
+ return "00";
+ }
+}
+
+static char
+convert_SNI_to_char(JA4::SNI SNI_type)
+{
+ return static_cast<char>(SNI_type);
+}
+
+static std::string
+convert_count_to_two_digit_string(std::size_t count)
+{
+ std::string result;
+ if (count <= 9) {
+ result.push_back('0');
+ }
+ // We could also clamp the lower bound to 1 since there must be at least 1
+ // cipher, but 0 is more helpful for debugging if the cipher list is empty.
+ result.append(std::to_string(std::clamp(count, std::size_t{0}, std::size_t{99})));
+ return result;
+}
+
+static std::string
+convert_ALPN_to_two_char_string(std::string_view ALPN)
+{
+ std::string result;
+ if (ALPN.empty()) {
+ result = "00";
+ } else {
+ result.push_back(ALPN.front());
+ result.push_back(ALPN.back());
+ }
+ return result;
+}
+
+std::string
+JA4::make_JA4_b_raw(TLSClientHelloSummary const &TLS_summary)
+{
+ std::string result;
+ result.reserve(12);
+ std::vector temp = TLS_summary.get_ciphers();
+ std::sort(temp.begin(), temp.end());
+
+ for (auto cipher : temp) {
+ result.append(hexify(cipher));
+ result.push_back(',');
+ }
+ remove_trailing_character(result);
+ return result;
+}
+
+std::string
+JA4::make_JA4_c_raw(TLSClientHelloSummary const &TLS_summary)
+{
+ std::string result;
+ result.reserve(12);
+ std::vector temp = TLS_summary.get_extensions();
+ std::sort(temp.begin(), temp.end());
+
+ for (auto extension : temp) {
+ result.append(hexify(extension));
+ result.push_back(',');
+ }
+ remove_trailing_character(result);
+ return result;
+}
+
+static void
+remove_trailing_character(std::string &s)
+{
+ if (!s.empty()) {
+ s.pop_back();
+ }
+}
+
+static std::string
+hexify(std::uint16_t n)
+{
+ char result[U16_HEX_BUF_SIZE + 1]{};
+ std::snprintf(result, sizeof(result), "%.4x", n);
+ return result;
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4.h b/plugins/experimental/jax_fingerprint/ja4/ja4.h
new file mode 100644
index 0000000000..b6be1e40cf
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4.h
@@ -0,0 +1,171 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <iterator>
+#include <string>
+#include <vector>
+
+namespace JA4
+{
+
+constexpr char PORTION_DELIMITER{'_'};
+
+enum class Protocol {
+ DTLS = 'd',
+ QUIC = 'q',
+ TLS = 't',
+};
+
+enum class SNI {
+ to_domain = 'd',
+ to_IP = 'i',
+};
+
+/**
+ * Represents the data sent in a TLS Client Hello needed for JA4 fingerprints.
+ */
+class TLSClientHelloSummary
+{
+public:
+ using difference_type = std::iterator_traits<std::vector<std::uint16_t>::iterator>::difference_type;
+
+ Protocol protocol;
+ std::uint16_t TLS_version{0}; // 0 is not the default, this is only to not have it un-initialized.
+ std::string ALPN;
+
+ std::vector<std::uint16_t> const &get_ciphers() const;
+ void add_cipher(std::uint16_t cipher);
+
+ std::vector<std::uint16_t> const &get_extensions() const;
+ void add_extension(std::uint16_t extension);
+
+ /**
+ * Get the number of ciphers excluding GREASE values.
+ *
+ * @return Returns the count of non-GREASE ciphers.
+ */
+ difference_type get_cipher_count() const;
+
+ /**
+ * Get the number of extensions excluding GREASE values.
+ *
+ * @return Returns the count of non-GREASE extensions.
+ */
+ difference_type get_extension_count() const;
+
+ /** Get the SNI type, domain or IP.
+ *
+ * @return Returns SNI::to_domain or SNI::to_IP.
+ */
+ SNI get_SNI_type() const;
+
+private:
+ std::vector<std::uint16_t> _ciphers;
+ std::vector<std::uint16_t> _extensions;
+ int _extension_count_including_sni_and_alpn{0};
+ SNI _SNI_type{SNI::to_IP};
+};
+
+/**
+ * Calculate the a portion of the JA4 fingerprint for the given client hello.
+ *
+ * The a portion of the fingerprint encodes the protocol, TLS version, SNI
+ * type, number of cipher suites, number of extensions, and first ALPN value.
+ *
+ * For more information see:
+ * https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md.
+ *
+ * @param TLS_summary The TLS client hello.
+ * @return Returns a string containing the a portion of the JA4 fingerprint.
+ */
+std::string make_JA4_a_raw(TLSClientHelloSummary const &TLS_summary);
+
+/**
+ * Calculate the b portion of the JA4 fingerprint for the given client hello.
+ *
+ * The b portion of the fingerprint is a comma-delimited list of lowercase hex
+ * numbers representing the cipher suites in sorted order. GREASE values are
+ * ignored.
+ *
+ * For more information see:
+ * https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md.
+ *
+ * @param TLS_summary The TLS client hello.
+ * @return Returns a string containing the b portion of the JA4 fingerprint.
+ */
+std::string make_JA4_b_raw(TLSClientHelloSummary const &TLS_summary);
+
+/**
+ * Calculate the c portion of the JA4 fingerprint for the given client hello.
+ *
+ * The b portion of the fingerprint is a comma-delimited list of lowercase hex
+ * numbers representing the extensions in sorted order. GREASE values and the
+ * SNI and ALPN extensions are ignored.
+ *
+ * For more information see:
+ * https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md.
+ *
+ * @param TLS_summary The TLS client hello.
+ * @return Returns a string containing the c portion of the JA4 fingerprint.
+ */
+std::string make_JA4_c_raw(TLSClientHelloSummary const &TLS_summary);
+
+/**
+ * Calculate the JA4 fingerprint for the given TLS client hello.
+ *
+ * @param TLS_summary The TLS client hello. If there was no ALPN in the
+ * Client Hello, TLS_summary.ALPN should either be empty or set to "00".
+ * Behavior when the number of digits in TLS_summary.TLS_version is greater
+ * than 2, the number of digits in TLS_summary.ALPN is greater than 2
+ * (except when TLS_summary.ALPN is empty) is unspecified.
+ * @param UnaryOp hasher A hash function. For a specification-compliant
+ * JA4 fingerprint, this should be a sha256 hash.
+ * @return Returns a string containing the JA4 fingerprint.
+ */
+template <typename UnaryOp>
+std::string
+make_JA4_fingerprint(TLSClientHelloSummary const &TLS_summary, UnaryOp hasher)
+{
+ std::string result;
+ result.append(make_JA4_a_raw(TLS_summary));
+ result.push_back(JA4::PORTION_DELIMITER);
+ result.append(hasher(make_JA4_b_raw(TLS_summary)).substr(0, 12));
+ result.push_back(JA4::PORTION_DELIMITER);
+ result.append(hasher(make_JA4_c_raw(TLS_summary)).substr(0, 12));
+ return result;
+}
+
+/**
+ * Check whether @a value is a GREASE value.
+ *
+ * These are reserved extensions randomly advertised to keep implementations
+ * well lubricated. They are ignored in all parts of JA4 because of their
+ * random nature.
+ *
+ * @return Returns true if the value is a GREASE value, false otherwise.
+ */
+bool is_GREASE(std::uint16_t value);
+
+} // end namespace JA4
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4_method.cc b/plugins/experimental/jax_fingerprint/ja4/ja4_method.cc
new file mode 100644
index 0000000000..7fcaaf2edc
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4_method.cc
@@ -0,0 +1,159 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "ts/ts.h"
+
+#include <plugin.h>
+#include <context.h>
+#include "ja4_method.h"
+#include "ja4.h"
+
+#include <openssl/sha.h>
+#include <openssl/ssl.h>
+
+constexpr unsigned int EXT_ALPN{0x10};
+constexpr unsigned int EXT_SUPPORTED_VERSIONS{0x2b};
+
+namespace ja4_method
+{
+
+void on_client_hello(JAxContext *, TSVConn);
+
+struct Method method = {
+ "JA4",
+ Method::Type::CONNECTION_BASED,
+ on_client_hello,
+ nullptr,
+};
+
+} // namespace ja4_method
+
+static std::uint16_t
+get_version(TSClientHello ch)
+{
+ unsigned char const *buf{};
+ std::size_t buflen{};
+ if (TS_SUCCESS == TSClientHelloExtensionGet(ch, EXT_SUPPORTED_VERSIONS, &buf, &buflen)) {
+ std::uint16_t max_version{0};
+ size_t versions_len = buf[0];
+
+ if (buflen < versions_len + 1) {
+ Dbg(dbg_ctl, "Malformed supported_versions extension (truncated vector)... using legacy version.");
+ return ch.get_version();
+ }
+
+ for (size_t i = 1; (i + 1) < (versions_len + 1); i += 2) {
+ std::uint16_t version = (buf[i] << 8) | buf[i + 1];
+ if (!JA4::is_GREASE(version) && version > max_version) {
+ max_version = version;
+ }
+ }
+ return max_version;
+ } else {
+ Dbg(dbg_ctl, "No supported_versions extension... using legacy version.");
+ return ch.get_version();
+ }
+}
+
+static std::string
+get_first_ALPN(TSClientHello ch)
+{
+ unsigned char const *buf{};
+ std::size_t buflen{};
+ std::string result{""};
+ if (TS_SUCCESS == TSClientHelloExtensionGet(ch, EXT_ALPN, &buf, &buflen)) {
+ // The first two bytes are a 16bit encoding of the total length.
+ TSAssert(buflen > 4);
+ unsigned char first_ALPN_length{buf[2]};
+ TSAssert(0 != first_ALPN_length);
+ result.assign(&buf[3], (&buf[3]) + first_ALPN_length);
+ }
+
+ return result;
+}
+
+static constexpr std::uint16_t
+make_word(unsigned char lowbyte, unsigned char highbyte)
+{
+ return (static_cast<std::uint16_t>(highbyte) << 8) | lowbyte;
+}
+
+static void
+add_ciphers(JA4::TLSClientHelloSummary &summary, TSClientHello ch)
+{
+ const uint8_t *buf = ch.get_cipher_suites();
+ size_t buflen = ch.get_cipher_suites_len();
+
+ if (buflen > 0) {
+ for (std::size_t i = 0; i + 1 < buflen; i += 2) {
+ summary.add_cipher(make_word(buf[i], buf[i + 1]));
+ }
+ } else {
+ Dbg(dbg_ctl, "Failed to get ciphers.");
+ }
+}
+
+static void
+add_extensions(JA4::TLSClientHelloSummary &summary, TSClientHello ch)
+{
+ for (auto ext_type : ch.get_extension_types()) {
+ summary.add_extension(ext_type);
+ }
+}
+
+static std::string
+hash_with_SHA256(std::string_view sv)
+{
+ Dbg(dbg_ctl, "Hashing %s", std::string{sv}.c_str());
+ unsigned char hash[SHA256_DIGEST_LENGTH];
+ SHA256(reinterpret_cast<unsigned char const *>(sv.data()), sv.size(), hash);
+ std::string result;
+ result.resize(SHA256_DIGEST_LENGTH * 2 + 1);
+ for (int i{0}; i < SHA256_DIGEST_LENGTH; ++i) {
+ std::snprintf(result.data() + (i * 2), result.size() - (i * 2), "%02x", hash[i]);
+ }
+ return result;
+}
+
+static std::string
+get_fingerprint(TSClientHello ch)
+{
+ JA4::TLSClientHelloSummary summary{};
+ summary.protocol = JA4::Protocol::TLS;
+ summary.TLS_version = get_version(ch);
+ summary.ALPN = get_first_ALPN(ch);
+ add_ciphers(summary, ch);
+ add_extensions(summary, ch);
+ std::string result{JA4::make_JA4_fingerprint(summary, hash_with_SHA256)};
+ return result;
+}
+
+void
+ja4_method::on_client_hello(JAxContext *ctx, TSVConn vconn)
+{
+ TSClientHello ch = TSVConnClientHelloGet(vconn);
+
+ if (!ch) {
+ Dbg(dbg_ctl, "Could not get TSClientHello object.");
+ } else {
+ ctx->set_fingerprint(get_fingerprint(ch));
+ }
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/ja4_method.h b/plugins/experimental/jax_fingerprint/ja4/ja4_method.h
new file mode 100644
index 0000000000..4b7abea288
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/ja4_method.h
@@ -0,0 +1,32 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include <method.h>
+
+namespace ja4_method
+{
+
+extern struct Method method;
+
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/test_ja4.cc b/plugins/experimental/jax_fingerprint/ja4/test_ja4.cc
new file mode 100644
index 0000000000..8860d105f1
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/test_ja4.cc
@@ -0,0 +1,425 @@
+/** @file
+ *
+ Unit tests for JA4 fingerprint calculation.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include "ja4.h"
+
+#include <catch2/catch_test_macros.hpp>
+
+#include <algorithm>
+#include <cctype>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+static std::string call_JA4(JA4::TLSClientHelloSummary const &TLS_summary);
+static std::string inc(std::string_view sv);
+
+TEST_CASE("JA4")
+{
+ JA4::TLSClientHelloSummary TLS_summary{};
+
+ SECTION("Given the protocol is TCP, "
+ "when we create a JA4 fingerprint, "
+ "then the first character thereof should be 't'.")
+ {
+ TLS_summary.protocol = JA4::Protocol::TLS;
+
+ CHECK("t" == call_JA4(TLS_summary).substr(0, 1));
+ }
+
+ SECTION("Given the protocol is QUIC, "
+ "when we create a JA4 fingerprint, "
+ "then the first character thereof should be 'q'.")
+ {
+ TLS_summary.protocol = JA4::Protocol::QUIC;
+ CHECK(call_JA4(TLS_summary).starts_with('q'));
+ }
+
+ SECTION("Given the protocol is DTLS, "
+ "when we create a JA4 fingerprint, "
+ "then the first character thereof should be 'd'.")
+ {
+ TLS_summary.protocol = JA4::Protocol::DTLS;
+ CHECK(call_JA4(TLS_summary).starts_with('d'));
+ }
+
+ SECTION("Given the TLS version is unknown, "
+ "when we create a JA4 fingerprint, "
+ "then indices [1,2] thereof should contain \"00\".")
+ {
+ TLS_summary.TLS_version = 0x123;
+ CHECK("00" == call_JA4(TLS_summary).substr(1, 2));
+ TLS_summary.TLS_version = 0x234;
+ CHECK("00" == call_JA4(TLS_summary).substr(1, 2));
+ }
+
+ SECTION("Given the TLS version is known, "
+ "when we create a JA4 fingerprint, "
+ "then indices [1,2] thereof should contain the correct value.")
+ {
+ std::unordered_map<std::uint16_t, std::string> values{
+ {0x304, "13"},
+ {0x303, "12"},
+ {0x302, "11"},
+ {0x301, "10"},
+ {0x300, "s3"},
+ {0x200, "s2"},
+ {0x100, "s1"},
+ {0xfeff, "d1"},
+ {0xfefd, "d2"},
+ {0xfefc, "d3"}
+ };
+ for (auto const &[version, expected] : values) {
+ CAPTURE(version, expected);
+ TLS_summary.TLS_version = version;
+ CHECK(expected == call_JA4(TLS_summary).substr(1, 2));
+ }
+ }
+
+ SECTION("Given the SNI extension is present, "
+ "when we create a JA4 fingerprint, "
+ "then index 3 thereof should contain 'd'.")
+ {
+ TLS_summary.add_extension(0x0);
+ CHECK("d" == call_JA4(TLS_summary).substr(3, 1));
+ }
+
+ SECTION("Given the SNI extension is not present, "
+ "when we create a JA4 fingerprint, "
+ "then index 3 thereof should contain 'i'.")
+ {
+ TLS_summary.add_extension(0x31);
+ CHECK("i" == call_JA4(TLS_summary).substr(3, 1));
+ }
+
+ SECTION("Given there is one cipher, "
+ "when we create a JA4 fingerprint, "
+ "then indices [4,5] thereof should contain \"01\".")
+ {
+ TLS_summary.add_cipher(1);
+ CHECK("01" == call_JA4(TLS_summary).substr(4, 2));
+ }
+
+ SECTION("Given there are 9 ciphers, "
+ "when we create a JA4 fingerprint, "
+ "then indices [4,5] thereof should contain \"09\".")
+ {
+ for (int i{0}; i < 9; ++i) {
+ TLS_summary.add_cipher(i);
+ }
+ CHECK("09" == call_JA4(TLS_summary).substr(4, 2));
+ }
+
+ SECTION("Given there are 10 ciphers, "
+ "when we create a JA4 fingerprint, "
+ "then indices [4,5] thereof should contain \"10\".")
+ {
+ for (int i{0}; i < 10; ++i) {
+ TLS_summary.add_cipher(i);
+ }
+ CHECK("10" == call_JA4(TLS_summary).substr(4, 2));
+ }
+
+ SECTION("Given there are more than 99 ciphers, "
+ "when we create a JA4 fingerprint, "
+ "then indices [4,5] thereof should contain \"99\".")
+ {
+ for (int i{0}; i < 100; ++i) {
+ TLS_summary.add_cipher(i);
+ }
+ CHECK("99" == call_JA4(TLS_summary).substr(4, 2));
+ }
+
+ SECTION("Given the ciphers include a GREASE value, "
+ "when we create a JA4 fingerprint, "
+ "then that value should not be included in the count.")
+ {
+ TLS_summary.add_cipher(0x0a0a);
+ TLS_summary.add_cipher(72);
+ CHECK("01" == call_JA4(TLS_summary).substr(4, 2));
+ }
+
+ SECTION("Given there are no extensions, "
+ "when we create a JA4 fingerprint, "
+ "then indices [6,7] thereof should contain \"00\".")
+ {
+ CHECK("00" == call_JA4(TLS_summary).substr(6, 2));
+ }
+
+ SECTION("Given there are 9 extensions, "
+ "when we create a JA4 fingerprint, "
+ "then indices [6,7] thereof should contain \"09\".")
+ {
+ for (int i{0}; i < 9; ++i) {
+ TLS_summary.add_extension(i);
+ }
+ CHECK("09" == call_JA4(TLS_summary).substr(6, 2));
+ }
+
+ SECTION("Given there are 99 extensions, "
+ "when we create a JA4 fingerprint, "
+ "then indices [6,7] thereof should contain \"99\".")
+ {
+ for (int i{0}; i < 99; ++i) {
+ TLS_summary.add_extension(i);
+ }
+ CHECK("99" == call_JA4(TLS_summary).substr(6, 2));
+ }
+
+ SECTION("Given there are more than 99 extensions, "
+ "when we create a JA4 fingerprint, "
+ "then indices [6,7] thereof should contain \"99\".")
+ {
+ for (int i{0}; i < 100; ++i) {
+ TLS_summary.add_extension(i);
+ }
+ CHECK("99" == call_JA4(TLS_summary).substr(6, 2));
+ }
+
+ SECTION("Given the extensions include a GREASE value, "
+ "when we create a JA4 fingerprint, "
+ "then that value should not be included in the count.")
+ {
+ TLS_summary.add_extension(2);
+ TLS_summary.add_extension(0x0a0a);
+ CHECK("01" == call_JA4(TLS_summary).substr(6, 2));
+ }
+
+ // These may be covered by the earlier tests as well, but this documents the
+ // behavior explicitly.
+ SECTION("When we create a JA4 fingerprint, "
+ "then the SNI and ALPN extensions should be included in the count.")
+ {
+ TLS_summary.add_extension(0x0);
+ TLS_summary.add_extension(0x10);
+ CHECK("02" == call_JA4(TLS_summary).substr(6, 2));
+ }
+
+ SECTION("Given the ALPN value is empty, "
+ "when we create a JA4 fingerprint, "
+ "then indices [8,9] thereof should contain \"00\".")
+ {
+ TLS_summary.ALPN = "";
+ CHECK("00" == call_JA4(TLS_summary).substr(8, 2));
+ }
+
+ // This should never happen in practice because all registered ALPN values
+ // are at least 2 characters long, but it's the correct behavior according
+ // to the spec. :-)
+ SECTION("Given the ALPN value is \"a\", "
+ "when we create a JA4 fingerprint, "
+ "then indices [8,9] thereof should contain \"aa\".")
+ {
+ TLS_summary.ALPN = 'a';
+ CHECK("aa" == call_JA4(TLS_summary).substr(8, 2));
+ }
+
+ SECTION("Given the ALPN value is \"h3\", "
+ "when we create a JA4 fingerprint, "
+ "then indices [8,9] thereof should contain \"h3\".")
+ {
+ TLS_summary.ALPN = "h3";
+ CHECK("h3" == call_JA4(TLS_summary).substr(8, 2));
+ }
+
+ SECTION("Given the ALPN value is \"imap\", "
+ "when we create a JA4 fingerprint, "
+ "then indices [8,9] thereof should contain \"ip\".")
+ {
+ TLS_summary.ALPN = "imap";
+ CHECK("ip" == call_JA4(TLS_summary).substr(8, 2));
+ }
+
+ SECTION("When we create a JA4 fingeprint, "
+ "then index 10 thereof should contain '_'.")
+ {
+ CHECK("_" == call_JA4(TLS_summary).substr(10, 1));
+ }
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then the b section should be passed through the hash function.")
+ {
+ TLS_summary.add_cipher(10);
+ CHECK("111b" == JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) { return inc(sv); }).substr(11, 4));
+ }
+
+ // As per the spec, we expect 4-character, comma-delimited hex values.
+ SECTION("Given only ciphers 2, 12, and 17 in that order, "
+ "when we create a JA4 fingerprint, "
+ "then the hash should be invoked with \"0002,000c,0011\".")
+ {
+ TLS_summary.add_cipher(2);
+ TLS_summary.add_cipher(12);
+ TLS_summary.add_cipher(17);
+ bool verified{false};
+ // INFO doesn't work from inside the lambda body. :/
+ JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+ if ("0002,000c,0011" == sv) {
+ verified = true;
+ }
+ return sv;
+ });
+ CHECK(verified);
+ }
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then the cipher values should be sorted before hashing.")
+ {
+ TLS_summary.add_cipher(17);
+ TLS_summary.add_cipher(2);
+ TLS_summary.add_cipher(12);
+ bool verified{false};
+ // INFO doesn't work from inside the lambda body. :/
+ JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+ if ("0002,000c,0011" == sv) {
+ verified = true;
+ }
+ return sv;
+ });
+ CHECK(verified);
+ }
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then GREASE values in the cipher list should be ignored.")
+ {
+ TLS_summary.add_cipher(0x0a0a);
+ TLS_summary.add_cipher(2);
+ bool verified{false};
+ // INFO doesn't work from inside the lambda body. :/
+ JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+ if ("0002" == sv) {
+ verified = true;
+ }
+ return sv;
+ });
+ CHECK(verified);
+ }
+
+ // All the tests from now on have enough ciphers to ensure a long enough
+ // hash using our default hash (the id function) so that the length of the
+ // JA4 fingerprint will be valid.
+ TLS_summary.add_cipher(1);
+ TLS_summary.add_cipher(2);
+ TLS_summary.add_cipher(3);
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then we should truncate the section b hash to 12 characters.")
+ {
+ CHECK("001,0002,000_" == JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) {
+ return sv.empty() ? sv : sv.substr(1);
+ }).substr(11, 13));
+ }
+
+ SECTION("When we create a JA4 fingeprint, "
+ "then index 10 thereof should contain '_'.")
+ {
+ CHECK("_" == call_JA4(TLS_summary).substr(23, 1));
+ }
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then the c section should be passed through the hash function.")
+ {
+ TLS_summary.add_extension(10);
+ CHECK("111b" == JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) { return inc(sv); }).substr(24, 4));
+ }
+
+ // As per the spec, we expect 4-character, comma-delimited hex values.
+ SECTION("Given only extensions 2, 12, and 17 in that order, "
+ "when we create a JA4 fingerprint, "
+ "then the hash should be invoked with \"0002,000c,0011\".")
+ {
+ TLS_summary.add_extension(2);
+ TLS_summary.add_extension(12);
+ TLS_summary.add_extension(17);
+
+ bool verified{false};
+ // INFO doesn't work from inside the lambda body. :/
+ JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+ if ("0002,000c,0011" == sv) {
+ verified = true;
+ }
+ return sv;
+ });
+ CHECK(verified);
+ }
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then the extension values should be sorted before hashing.")
+ {
+ TLS_summary.add_extension(17);
+ TLS_summary.add_extension(2);
+ TLS_summary.add_extension(12);
+ bool verified{false};
+ // INFO doesn't work from inside the lambda body. :/
+ JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+ if ("0002,000c,0011" == sv) {
+ verified = true;
+ }
+ return sv;
+ });
+ CHECK(verified);
+ }
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then we ignore GREASE, SNI, ALPN, and SNI values in the extensions.")
+ {
+ TLS_summary.add_extension(0x0a0a);
+ TLS_summary.add_extension(0x0);
+ TLS_summary.add_extension(0x10);
+ TLS_summary.add_extension(5);
+ bool verified{false};
+ // INFO doesn't work from inside the lambda body. :/
+ JA4::make_JA4_fingerprint(TLS_summary, [&verified](std::string_view sv) {
+ if ("0005" == sv) {
+ verified = true;
+ }
+ return sv;
+ });
+ CHECK(verified);
+ }
+
+ SECTION("When we create a JA4 fingerprint, "
+ "then we total length of the fingerprint should be 36 characters.")
+ {
+ TLS_summary.add_extension(1);
+ TLS_summary.add_extension(2);
+ TLS_summary.add_extension(3);
+ CHECK(36 == call_JA4(TLS_summary).size());
+ }
+}
+
+std::string
+call_JA4(JA4::TLSClientHelloSummary const &TLS_summary)
+{
+ return JA4::make_JA4_fingerprint(TLS_summary, [](std::string_view sv) { return sv; });
+}
+
+std::string
+inc(std::string_view sv)
+{
+ std::string result;
+ result.resize(sv.size());
+ std::transform(sv.begin(), sv.end(), result.begin(), [](char c) { return c + 1; });
+ return result;
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4/tls_client_hello_summary.cc b/plugins/experimental/jax_fingerprint/ja4/tls_client_hello_summary.cc
new file mode 100644
index 0000000000..cc35a1c83b
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4/tls_client_hello_summary.cc
@@ -0,0 +1,112 @@
+/** @file
+ *
+ TLSClientHelloSummary data structure for JA4 fingerprint calculation.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include "ja4.h"
+
+#include <algorithm>
+#include <array>
+#include <cstdint>
+#include <functional>
+#include <vector>
+
+namespace
+{
+
+constexpr std::array<std::uint16_t, 16> GREASE_values{0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a,
+ 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba, 0xcaca, 0xdada, 0xeaea, 0xfafa};
+constexpr std::uint16_t extension_SNI{0x0};
+constexpr std::uint16_t extension_ALPN{0x10};
+
+} // end anonymous namespace
+
+static bool is_ignored_non_GREASE_extension(std::uint16_t extension);
+
+std::vector<std::uint16_t> const &
+JA4::TLSClientHelloSummary::get_ciphers() const
+{
+ return this->_ciphers;
+}
+
+void
+JA4::TLSClientHelloSummary::add_cipher(std::uint16_t cipher)
+{
+ if (is_GREASE(cipher)) {
+ return;
+ }
+
+ this->_ciphers.push_back(cipher);
+}
+
+std::vector<std::uint16_t> const &
+JA4::TLSClientHelloSummary::get_extensions() const
+{
+ return this->_extensions;
+}
+
+void
+JA4::TLSClientHelloSummary::add_extension(std::uint16_t extension)
+{
+ if (is_GREASE(extension)) {
+ return;
+ }
+
+ if (extension_SNI == extension) {
+ this->_SNI_type = SNI::to_domain;
+ }
+
+ ++this->_extension_count_including_sni_and_alpn;
+ if (!is_ignored_non_GREASE_extension(extension)) {
+ this->_extensions.push_back(extension);
+ }
+}
+
+JA4::TLSClientHelloSummary::difference_type
+JA4::TLSClientHelloSummary::get_cipher_count() const
+{
+ return this->_ciphers.size();
+}
+
+JA4::TLSClientHelloSummary::difference_type
+JA4::TLSClientHelloSummary::get_extension_count() const
+{
+ return this->_extension_count_including_sni_and_alpn;
+}
+
+bool
+is_ignored_non_GREASE_extension(std::uint16_t extension)
+{
+ return (extension_SNI == extension) || (extension_ALPN == extension);
+}
+
+JA4::SNI
+JA4::TLSClientHelloSummary::get_SNI_type() const
+{
+ return this->_SNI_type;
+}
+
+bool
+JA4::is_GREASE(std::uint16_t value)
+{
+ return std::binary_search(GREASE_values.begin(), GREASE_values.end(), value);
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/datasource.cc b/plugins/experimental/jax_fingerprint/ja4h/datasource.cc
new file mode 100644
index 0000000000..dcddfa4255
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/datasource.cc
@@ -0,0 +1,44 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "datasource.h"
+
+#include <cctype>
+#include <algorithm>
+
+bool
+Datasource::_should_include_field(std::string_view name)
+{
+ constexpr std::string_view COOKIE{"cookie"};
+ constexpr std::string_view REFERER{"referer"};
+
+ if (name.length() == COOKIE.length()) {
+ if (std::equal(name.begin(), name.end(), COOKIE.begin(), [](char c1, char c2) { return std::tolower(c1) == c2; })) {
+ return false;
+ }
+ } else if (name.length() == REFERER.length()) {
+ if (std::equal(name.begin(), name.end(), REFERER.begin(), [](char c1, char c2) { return std::tolower(c1) == c2; })) {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/datasource.h b/plugins/experimental/jax_fingerprint/ja4h/datasource.h
new file mode 100644
index 0000000000..3833410567
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/datasource.h
@@ -0,0 +1,42 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include <string_view>
+
+class Datasource
+{
+public:
+ Datasource() = default;
+ virtual ~Datasource() = default;
+ virtual std::string_view get_method() = 0;
+ virtual int get_version() = 0;
+ virtual bool has_cookie_field() = 0;
+ virtual bool has_referer_field() = 0;
+ virtual int get_field_count() = 0;
+ virtual std::string_view get_accept_language() = 0;
+ virtual void get_headers_hash(unsigned char out[32]) = 0;
+
+protected:
+ bool _should_include_field(std::string_view name);
+};
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h.cc b/plugins/experimental/jax_fingerprint/ja4h/ja4h.cc
new file mode 100644
index 0000000000..7175797035
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h.cc
@@ -0,0 +1,118 @@
+/** @file
+ *
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include "ja4h.h"
+#include <openssl/sha.h>
+#include <algorithm>
+#include <cctype>
+
+static void
+generate_ja4h_a(char *out, Datasource &datasource)
+{
+ std::string_view method = datasource.get_method();
+ if (method.length() >= 2) {
+ out[0] = std::tolower(method[0]);
+ out[1] = std::tolower(method[1]);
+ } else {
+ // This case seems to be undefined on the spec
+ out[0] = 'x';
+ out[1] = 'x';
+ }
+ int version = datasource.get_version();
+ out[2] = 0x30 | (version >> 16);
+ out[3] = 0x30 | (version & 0xFFFF);
+ out[4] = datasource.has_cookie_field() ? 'c' : 'n';
+ out[5] = datasource.has_referer_field() ? 'r' : 'n';
+ int field_count = datasource.get_field_count();
+ if (field_count < 100) {
+ out[6] = 0x30 | (field_count / 10);
+ out[7] = 0x30 | (field_count % 10);
+ } else {
+ out[6] = '9';
+ out[7] = '9';
+ }
+ std::string_view accept_lang = datasource.get_accept_language();
+ if (accept_lang.empty()) {
+ out[8] = '0';
+ out[9] = '0';
+ out[10] = '0';
+ out[11] = '0';
+ } else {
+ for (int i = 0, j = 0; i < 4; ++i) {
+ while (static_cast<size_t>(j) < accept_lang.size() && accept_lang[j] < 'A' && accept_lang[j] != ';') {
+ ++j;
+ }
+ if (static_cast<size_t>(j) == accept_lang.size() || accept_lang[j] == ';') {
+ out[8 + i] = '0';
+ } else {
+ out[8 + i] = std::tolower(accept_lang[j]);
+ ++j;
+ }
+ }
+ }
+}
+
+static void
+generate_ja4h_b(char *out, Datasource &datasource)
+{
+ unsigned char hash[32];
+
+ datasource.get_headers_hash(hash);
+
+ for (int i = 0; i < 6; ++i) {
+ unsigned int h = hash[i] >> 4;
+ unsigned int l = hash[i] & 0x0F;
+ out[i * 2] = h <= 9 ? ('0' + h) : ('a' + h - 10);
+ out[i * 2 + 1] = l <= 9 ? ('0' + l) : ('a' + l - 10);
+ }
+}
+
+static void
+generate_ja4h_c(char *out, Datasource & /* datasource ATS_UNUSED */)
+{
+ // Not implemented
+ for (int i = 0; i < 12; ++i) {
+ out[i] = '0';
+ }
+}
+
+static void
+generate_ja4h_d(char *out, Datasource & /* datasource ATS_UNUSED */)
+{
+ // Not implemented
+ for (int i = 0; i < 12; ++i) {
+ out[i] = '0';
+ }
+}
+
+void
+generate_ja4h_fingerprint(char *out, Datasource &datasource)
+{
+ generate_ja4h_a(&(out[PART_A_POSITION]), datasource);
+ out[DELIMITER_1_POSITION] = DELIMITER;
+ generate_ja4h_b(&(out[PART_B_POSITION]), datasource);
+ out[DELIMITER_2_POSITION] = DELIMITER;
+ generate_ja4h_c(&(out[PART_C_POSITION]), datasource);
+ out[DELIMITER_3_POSITION] = DELIMITER;
+ generate_ja4h_d(&(out[PART_D_POSITION]), datasource);
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h.h b/plugins/experimental/jax_fingerprint/ja4h/ja4h.h
new file mode 100644
index 0000000000..a49aa62c91
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h.h
@@ -0,0 +1,41 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include "datasource.h"
+
+constexpr int FINGERPRINT_LENGTH = 51;
+constexpr size_t PART_A_POSITION = 0;
+constexpr size_t PART_B_POSITION = 13;
+constexpr size_t PART_C_POSITION = 26;
+constexpr size_t PART_D_POSITION = 39;
+constexpr size_t PART_A_LENGTH = 12;
+constexpr size_t PART_B_LENGTH = 12;
+constexpr size_t PART_C_LENGTH = 12;
+constexpr size_t PART_D_LENGTH = 12;
+constexpr char DELIMITER = '-';
+constexpr size_t DELIMITER_1_POSITION = 12;
+constexpr size_t DELIMITER_2_POSITION = 25;
+constexpr size_t DELIMITER_3_POSITION = 38;
+
+void generate_ja4h_fingerprint(char *out, Datasource &datasource);
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.cc b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.cc
new file mode 100644
index 0000000000..c559b7cfbd
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.cc
@@ -0,0 +1,173 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "ts/ts.h"
+
+#include <plugin.h>
+#include <context.h>
+#include "ja4h_method.h"
+#include "ja4h.h"
+#include "datasource.h"
+
+#include "openssl/sha.h"
+
+namespace ja4h_method
+{
+void on_request(JAxContext *, TSHttpTxn);
+
+struct Method method = {
+ "JA4H",
+ Method::Type::REQUEST_BASED,
+ nullptr,
+ on_request,
+};
+
+class TxnDatasource : public Datasource
+{
+public:
+ TxnDatasource(TSHttpTxn txnp);
+ ~TxnDatasource();
+ std::string_view get_method() override;
+ int get_version() override;
+ bool has_cookie_field() override;
+ bool has_referer_field() override;
+ int get_field_count() override;
+ std::string_view get_accept_language() override;
+ void get_headers_hash(unsigned char out[32]) override;
+
+private:
+ TSHttpTxn _txn;
+ TSMBuffer _request = nullptr;
+ TSMLoc _req_hdr = nullptr;
+};
+
+TxnDatasource::TxnDatasource(TSHttpTxn txnp) : _txn(txnp)
+{
+ TSHttpTxnClientReqGet(txnp, &(this->_request), &(this->_req_hdr));
+}
+
+TxnDatasource::~TxnDatasource()
+{
+ if (this->_request != nullptr) {
+ TSHandleMLocRelease(this->_request, TS_NULL_MLOC, this->_req_hdr);
+ }
+}
+
+std::string_view
+TxnDatasource::get_method()
+{
+ if (this->_request == nullptr) {
+ return "";
+ }
+
+ int method_len;
+ const char *method = TSHttpHdrMethodGet(this->_request, this->_req_hdr, &method_len);
+
+ return {method, static_cast<size_t>(method_len)};
+}
+
+int
+TxnDatasource::get_version()
+{
+ if (TSHttpTxnClientProtocolStackContains(this->_txn, "h2")) {
+ return 2 << 16;
+ } else if (TSHttpTxnClientProtocolStackContains(this->_txn, "h3")) {
+ return 3 << 16;
+ } else {
+ return TSHttpHdrVersionGet(this->_request, this->_req_hdr);
+ }
+}
+
+bool
+TxnDatasource::has_cookie_field()
+{
+ TSMLoc mloc = TSMimeHdrFieldFind(this->_request, this->_req_hdr, TS_MIME_FIELD_COOKIE, TS_MIME_LEN_COOKIE);
+ if (mloc) {
+ TSHandleMLocRelease(this->_request, this->_req_hdr, mloc);
+ }
+ return mloc != TS_NULL_MLOC;
+}
+
+bool
+TxnDatasource::has_referer_field()
+{
+ TSMLoc mloc = TSMimeHdrFieldFind(this->_request, this->_req_hdr, TS_MIME_FIELD_REFERER, TS_MIME_LEN_REFERER);
+ if (mloc) {
+ TSHandleMLocRelease(this->_request, this->_req_hdr, mloc);
+ }
+ return mloc != TS_NULL_MLOC;
+}
+
+int
+TxnDatasource::get_field_count()
+{
+ return TSMimeHdrFieldsCount(this->_request, this->_req_hdr);
+}
+
+std::string_view
+TxnDatasource::get_accept_language()
+{
+ TSMLoc mloc = TSMimeHdrFieldFind(this->_request, this->_req_hdr, TS_MIME_FIELD_ACCEPT_LANGUAGE, TS_MIME_LEN_ACCEPT_LANGUAGE);
+ if (mloc == TS_NULL_MLOC) {
+ return {};
+ }
+ int value_len;
+ const char *value = TSMimeHdrFieldValueStringGet(this->_request, this->_req_hdr, mloc, 0, &value_len);
+ TSHandleMLocRelease(this->_request, this->_req_hdr, mloc);
+ return {value, static_cast<size_t>(value_len)};
+}
+
+void
+TxnDatasource::get_headers_hash(unsigned char out[32])
+{
+ SHA256_CTX sha256ctx;
+ SHA256_Init(&sha256ctx);
+
+ TSMLoc field_loc = TSMimeHdrFieldGet(this->_request, this->_req_hdr, 0);
+
+ while (field_loc != TS_NULL_MLOC) {
+ int field_name_len;
+ char *field_name = const_cast<char *>(TSMimeHdrFieldNameGet(this->_request, this->_req_hdr, field_loc, &field_name_len));
+
+ if (this->_should_include_field({field_name, static_cast<size_t>(field_name_len)})) {
+ SHA256_Update(&sha256ctx, field_name, field_name_len);
+ }
+
+ TSMLoc next_field_loc = TSMimeHdrFieldNext(this->_request, this->_req_hdr, field_loc);
+ TSHandleMLocRelease(this->_request, this->_req_hdr, field_loc);
+ field_loc = next_field_loc;
+ }
+
+ SHA256_Final(out, &sha256ctx);
+}
+
+} // namespace ja4h_method
+
+void
+ja4h_method::on_request(JAxContext *ctx, TSHttpTxn txnp)
+{
+ char fingerprint[FINGERPRINT_LENGTH];
+ TxnDatasource datasource{txnp};
+
+ generate_ja4h_fingerprint(fingerprint, datasource);
+
+ ctx->set_fingerprint({fingerprint, FINGERPRINT_LENGTH});
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.h b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.h
new file mode 100644
index 0000000000..e64901b815
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/ja4h_method.h
@@ -0,0 +1,32 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include <method.h>
+
+namespace ja4h_method
+{
+
+extern struct Method method;
+
+}
diff --git a/plugins/experimental/jax_fingerprint/ja4h/test_ja4h.cc b/plugins/experimental/jax_fingerprint/ja4h/test_ja4h.cc
new file mode 100644
index 0000000000..2ae5ef8878
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/ja4h/test_ja4h.cc
@@ -0,0 +1,168 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "ja4h.h"
+#include "datasource.h"
+
+#include <openssl/sha.h>
+#include <catch2/catch_test_macros.hpp>
+
+#include <map>
+
+class MockDatasource : public Datasource
+{
+public:
+ MockDatasource() {}
+ ~MockDatasource() {}
+
+ std::string_view
+ get_method() override
+ {
+ return this->_method;
+ }
+ int
+ get_version() override
+ {
+ return this->_version;
+ }
+ bool
+ has_cookie_field() override
+ {
+ return this->_fields.contains("Cookie");
+ }
+ bool
+ has_referer_field() override
+ {
+ return this->_fields.contains("Referer");
+ }
+ int
+ get_field_count() override
+ {
+ return this->_fields.size();
+ }
+ std::string_view
+ get_accept_language() override
+ {
+ if (auto ite = this->_fields.find("Accept-Language"); ite != this->_fields.end()) {
+ return ite->second;
+ } else {
+ return {};
+ }
+ }
+ void
+ get_headers_hash(unsigned char out[32]) override
+ {
+ SHA256_CTX sha256ctx;
+ SHA256_Init(&sha256ctx);
+
+ for (auto &&ite : this->_fields) {
+ if (this->_should_include_field({ite.first.c_str(), ite.first.size()})) {
+ SHA256_Update(&sha256ctx, ite.first.c_str(), ite.first.size());
+ }
+ }
+
+ SHA256_Final(out, &sha256ctx);
+ }
+
+ void
+ set_method(std::string method)
+ {
+ this->_method = method;
+ }
+ void
+ set_version(int version)
+ {
+ this->_version = version;
+ }
+ void
+ set_fields(std::map<std::string, std::string> fields)
+ {
+ this->_fields = fields;
+ }
+
+private:
+ std::string _method;
+ int _version;
+ std::map<std::string, std::string> _fields;
+};
+
+std::string_view
+SHA256_12(std::string_view in)
+{
+ uint8_t hash[32];
+ SHA256(reinterpret_cast<const uint8_t *>(in.data()), in.size(), hash);
+
+ static char out[12];
+ for (int i = 0; i < 6; ++i) {
+ uint8_t h = hash[i] >> 4;
+ uint8_t l = hash[i] & 0x0F;
+ out[i * 2] = h <= 9 ? '0' + h : 'a' + h - 10;
+ out[i * 2 + 1] = l <= 9 ? '0' + l : 'a' + l - 10;
+ }
+ return {out, sizeof(out)};
+}
+
+TEST_CASE("JA4H")
+{
+ MockDatasource datasource;
+ char fingerprint[FINGERPRINT_LENGTH];
+
+ SECTION("HTTP/1.0 GET with Host")
+ {
+ datasource.set_method("GET");
+ datasource.set_version(1 << 16 | 0);
+ datasource.set_fields({
+ {"Host", "abc.example"},
+ });
+
+ generate_ja4h_fingerprint(fingerprint, datasource);
+
+ std::string_view fingerprint_sv{fingerprint, sizeof(fingerprint)};
+ CHECK(fingerprint_sv.substr(PART_A_POSITION, PART_A_LENGTH) == "ge10nn010000");
+ CHECK(fingerprint_sv[DELIMITER_1_POSITION] == DELIMITER);
+ CHECK(fingerprint_sv.substr(PART_B_POSITION, PART_B_LENGTH) == SHA256_12("Host"));
+ CHECK(fingerprint_sv[DELIMITER_2_POSITION] == DELIMITER);
+ CHECK(fingerprint_sv.substr(PART_C_POSITION, PART_C_LENGTH) == "000000000000");
+ CHECK(fingerprint_sv[DELIMITER_3_POSITION] == DELIMITER);
+ CHECK(fingerprint_sv.substr(PART_D_POSITION, PART_D_LENGTH) == "000000000000");
+ }
+
+ SECTION("HTTP/1.1 POST with Accept-Language and Referer")
+ {
+ datasource.set_method("POST");
+ datasource.set_version(1 << 16 | 1);
+ datasource.set_fields({
+ {"Accept-Language", "en" },
+ {"Referer", "https://xyz.example/foo"},
+ });
+
+ generate_ja4h_fingerprint(fingerprint, datasource);
+
+ std::string_view fingerprint_sv{fingerprint, sizeof(fingerprint)};
+ CHECK(fingerprint_sv.substr(PART_A_POSITION, PART_A_LENGTH) == "po11nr02en00");
+ CHECK(fingerprint_sv[DELIMITER_1_POSITION] == DELIMITER);
+ CHECK(fingerprint_sv.substr(PART_B_POSITION, PART_B_LENGTH) == SHA256_12("Accept-Language"));
+ CHECK(fingerprint_sv[DELIMITER_2_POSITION] == DELIMITER);
+ CHECK(fingerprint_sv.substr(PART_C_POSITION, PART_C_LENGTH) == "000000000000");
+ CHECK(fingerprint_sv[DELIMITER_3_POSITION] == DELIMITER);
+ CHECK(fingerprint_sv.substr(PART_D_POSITION, PART_D_LENGTH) == "000000000000");
+ }
+}
diff --git a/plugins/experimental/jax_fingerprint/log.cc b/plugins/experimental/jax_fingerprint/log.cc
new file mode 100644
index 0000000000..e18ad3d49c
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/log.cc
@@ -0,0 +1,52 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "plugin.h"
+#include "log.h"
+
+#include "ts/ts.h"
+
+#include <string>
+
+bool
+create_log_file(const std::string &filename, TSTextLogObject &log_handle)
+{
+ return (TS_SUCCESS == TSTextLogObjectCreate(filename.c_str(), TS_LOG_MODE_ADD_TIMESTAMP, &log_handle));
+}
+
+void
+log_fingerprint(const JAxContext *ctx, TSTextLogObject &log_handle)
+{
+ if (log_handle == nullptr) {
+ Dbg(dbg_ctl, "Log handle is not initialized.");
+ return;
+ }
+ if (TS_ERROR == TSTextLogObjectWrite(log_handle, "Client: %s\t%s: %s", ctx->get_addr(), ctx->get_method_name(),
+ ctx->get_fingerprint().c_str())) {
+ Dbg(dbg_ctl, "Failed to write to log!");
+ }
+}
+
+void
+flush_log_file(TSTextLogObject &log_handle)
+{
+ TSTextLogObjectDestroy(log_handle);
+}
diff --git a/plugins/experimental/jax_fingerprint/log.h b/plugins/experimental/jax_fingerprint/log.h
new file mode 100644
index 0000000000..8dad8fa2be
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/log.h
@@ -0,0 +1,30 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+#include "context.h"
+
+bool create_log_file(const std::string &filename, TSTextLogObject &log_handle);
+void log_fingerprint(const JAxContext *ctx, TSTextLogObject &log_handle);
+void flush_log_file(TSTextLogObject &log_handle);
diff --git a/plugins/experimental/jax_fingerprint/method.h b/plugins/experimental/jax_fingerprint/method.h
new file mode 100644
index 0000000000..710d391849
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/method.h
@@ -0,0 +1,68 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+
+#include "context.h"
+#include <string_view>
+
+/** @brief Handler invoked on TLS ClientHello for connection-based fingerprinting. */
+using ClientHelloHandler = void (*)(JAxContext *, TSVConn);
+
+/** @brief Handler invoked on HTTP request read for request-based fingerprinting. */
+using RequestReadHandler = void (*)(JAxContext *, TSHttpTxn);
+
+/**
+ * @brief Describes a fingerprinting method (e.g. JA3, JA4, JA4H).
+ *
+ * Each method has a type that determines when it runs:
+ * - @c CONNECTION_BASED methods fire on the TLS ClientHello via @c on_client_hello.
+ * - @c REQUEST_BASED methods fire on the HTTP request via @c on_request.
+ *
+ * Concrete method instances are defined in per-method directories
+ * (ja3/, ja4/, ja4h/) and selected by the plugin configuration.
+ */
+struct Method {
+ enum class Type {
+ /** Fingerprint derived from TLS ClientHello. */
+ CONNECTION_BASED,
+ /** Fingerprint derived from the HTTP request. */
+ REQUEST_BASED,
+ };
+
+ /**
+ * Human-readable method name (e.g. "ja3").
+ * Must reference a null-terminated string.
+ */
+ std::string_view name;
+
+ /** When this method runs. */
+ Type type;
+
+ /** Callback for CONNECTION_BASED methods (may be nullptr). */
+ ClientHelloHandler on_client_hello;
+
+ /** Callback for REQUEST_BASED methods (may be nullptr). */
+ RequestReadHandler on_request;
+};
diff --git a/plugins/experimental/jax_fingerprint/plugin.cc b/plugins/experimental/jax_fingerprint/plugin.cc
new file mode 100644
index 0000000000..e719836b6d
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/plugin.cc
@@ -0,0 +1,480 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "plugin.h"
+#include "config.h"
+#include "context.h"
+#include "userarg.h"
+#include "method.h"
+#include "header.h"
+#include "log.h"
+
+#include "ja4/ja4_method.h"
+#include "ja4h/ja4h_method.h"
+#include "ja3/ja3_method.h"
+
+#include <ts/apidefs.h>
+#include <ts/ts.h>
+#include <ts/remap.h>
+#include <ts/remap_version.h>
+
+#include <getopt.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <version>
+
+DbgCtl dbg_ctl{PLUGIN_NAME};
+
+namespace
+{
+
+} // end anonymous namespace
+
+static bool
+read_config_option(int argc, char const *argv[], PluginConfig &config)
+{
+ const struct option longopts[] = {
+ {"standalone", no_argument, nullptr, 's'},
+ {"method", required_argument, nullptr, 'M'}, // JA4, JA4H, or JA3
+ {"mode", required_argument, nullptr, 'm'}, // overwrite, keep, or append
+ {"header", required_argument, nullptr, 'h'},
+ {"via-header", required_argument, nullptr, 'v'},
+ {"log-filename", required_argument, nullptr, 'f'},
+ {"servernames", required_argument, nullptr, 'S'},
+ {nullptr, 0, nullptr, 0 }
+ };
+
+ optind = 0;
+ int opt{0};
+ while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "", longopts, nullptr)) >= 0) {
+ switch (opt) {
+ case '?':
+ Dbg(dbg_ctl, "Unrecognized command argument.");
+ break;
+ case 'M':
+ if (strcmp("JA4", optarg) == 0) {
+ config.method = ja4_method::method;
+ } else if (strcmp("JA4H", optarg) == 0) {
+ config.method = ja4h_method::method;
+ } else if (strcmp("JA3", optarg) == 0) {
+ config.method = ja3_method::method;
+ } else {
+ Dbg(dbg_ctl, "Unexpected method: %s", optarg);
+ return false;
+ }
+ break;
+ case 'm':
+ if (strcmp("overwrite", optarg) == 0) {
+ config.mode = Mode::OVERWRITE;
+ } else if (strcmp("keep", optarg) == 0) {
+ config.mode = Mode::KEEP;
+ } else if (strcmp("append", optarg) == 0) {
+ config.mode = Mode::APPEND;
+ } else {
+ Dbg(dbg_ctl, "Unexpected mode: %s", optarg);
+ return false;
+ }
+ break;
+ case 'h':
+ config.header_name = {optarg, strlen(optarg)};
+ break;
+ case 'v':
+ config.via_header_name = {optarg, strlen(optarg)};
+ break;
+ case 'f':
+ config.log_filename = {optarg, strlen(optarg)};
+ break;
+ case 's':
+ config.standalone = true;
+ break;
+ case 'S':
+ for (std::string_view input(optarg, strlen(optarg)); !input.empty();) {
+ auto pos = input.find(',');
+ config.servernames.emplace(input.substr(0, pos));
+ input.remove_prefix(pos == std::string_view::npos ? input.size() : pos + 1);
+ }
+ break;
+ case 0:
+ case -1:
+ break;
+ default:
+ Dbg(dbg_ctl, "Unexpected options error.");
+ return false;
+ }
+ }
+
+ if (config.method.name == "uninitialized") {
+ TSError("[%s] Method must be specified", PLUGIN_NAME);
+ return false;
+ }
+
+ Dbg(dbg_ctl, "JAx method is %s", config.method.name.data());
+ Dbg(dbg_ctl, "JAx mode is %d", static_cast<int>(config.mode));
+ Dbg(dbg_ctl, "JAx header is %s", !config.header_name.empty() ? config.header_name.c_str() : "DISABLED");
+ Dbg(dbg_ctl, "JAx via-header is %s", !config.via_header_name.empty() ? config.via_header_name.c_str() : "DISABLED");
+ Dbg(dbg_ctl, "JAx log file is %s", !config.log_filename.empty() ? config.log_filename.c_str() : "DISABLED");
+ Dbg(dbg_ctl, "JAx standalone mode is %s", config.standalone ? "ENABLED" : "DISABLED");
+ for (auto &&servername : config.servernames) {
+ Dbg(dbg_ctl, "%s", servername.c_str());
+ }
+
+ return true;
+}
+
+void
+modify_headers(JAxContext *ctx, TSHttpTxn txnp, PluginConfig &config)
+{
+ if (!ctx->get_fingerprint().empty()) {
+ switch (config.mode) {
+ case Mode::KEEP:
+ if (!config.header_name.empty() && !has_header(txnp, config.header_name)) {
+ set_header(txnp, config.header_name, ctx->get_fingerprint());
+ }
+ if (!config.via_header_name.empty() && !has_header(txnp, config.via_header_name)) {
+ set_via_header(txnp, config.via_header_name);
+ }
+ break;
+ case Mode::OVERWRITE:
+ if (!config.header_name.empty()) {
+ set_header(txnp, config.header_name, ctx->get_fingerprint());
+ }
+ if (!config.via_header_name.empty()) {
+ set_via_header(txnp, config.via_header_name);
+ }
+ break;
+ case Mode::APPEND:
+ if (!config.header_name.empty()) {
+ append_header(txnp, config.header_name, ctx->get_fingerprint());
+ }
+ if (!config.via_header_name.empty()) {
+ append_via_header(txnp, config.via_header_name);
+ }
+ break;
+ default:
+ break;
+ }
+ } else {
+ Dbg(dbg_ctl, "No fingerprint attached to vconn!");
+ if (config.mode == Mode::OVERWRITE) {
+ if (!config.header_name.empty()) {
+ remove_header(txnp, config.header_name);
+ }
+ if (!config.via_header_name.empty()) {
+ remove_header(txnp, config.via_header_name);
+ }
+ }
+ }
+}
+
+int
+handle_client_hello(void *edata, PluginConfig &config)
+{
+ TSVConn vconn = static_cast<TSVConn>(edata);
+ JAxContext *ctx = get_user_arg(vconn, config);
+
+ if (!config.servernames.empty()) {
+ const char *servername;
+ int servername_len;
+ servername = TSVConnSslSniGet(vconn, &servername_len);
+ if (servername != nullptr && servername_len > 0) {
+#ifdef __cpp_lib_generic_unordered_lookup
+ if (!config.servernames.contains(std::string_view(servername, servername_len))) {
+#else
+ if (!config.servernames.contains({servername, static_cast<size_t>(servername_len)})) {
+#endif
+ Dbg(dbg_ctl, "Server name %.*s is not in the server name set", servername_len, servername);
+ TSVConnReenable(vconn);
+ return TS_SUCCESS;
+ }
+ } else {
+ Dbg(dbg_ctl, "No SNI present but server name filtering is configured; skipping fingerprint generation");
+ TSVConnReenable(vconn);
+ return TS_SUCCESS;
+ }
+ }
+
+ if (nullptr == ctx) {
+ ctx = new JAxContext(config.method.name.data(), TSNetVConnRemoteAddrGet(vconn));
+ set_user_arg(vconn, config, ctx);
+ }
+
+ if (config.method.on_client_hello) {
+ config.method.on_client_hello(ctx, vconn);
+ }
+
+ TSVConnReenable(vconn);
+
+ return TS_SUCCESS;
+}
+
+int
+handle_read_request_hdr(void *edata, PluginConfig &config)
+{
+ TSHttpTxn txnp = static_cast<TSHttpTxn>(edata);
+ TSHttpSsn ssnp = TSHttpTxnSsnGet(txnp);
+ if (ssnp == nullptr) {
+ Dbg(dbg_ctl, "Failed to get ssn object.");
+ return TS_SUCCESS;
+ }
+
+ TSVConn vconn = TSHttpSsnClientVConnGet(ssnp);
+ if (vconn == nullptr) {
+ Dbg(dbg_ctl, "Failed to get vconn object.");
+ return TS_SUCCESS;
+ }
+
+ void *container;
+ if (config.method.type == Method::Type::CONNECTION_BASED) {
+ container = vconn;
+ } else {
+ container = txnp;
+ }
+ JAxContext *ctx = get_user_arg(container, config);
+ if (nullptr == ctx) {
+ if (container == vconn) {
+ // JAxContext should be created on client hello hook
+ Dbg(dbg_ctl, "No context. Skipping.");
+ return TS_SUCCESS;
+ }
+ ctx = new JAxContext(config.method.name.data(), TSNetVConnRemoteAddrGet(vconn));
+ set_user_arg(container, config, ctx);
+ }
+
+ if (config.method.on_request) {
+ config.method.on_request(ctx, txnp);
+ }
+
+ if (!config.log_filename.empty()) {
+ log_fingerprint(ctx, config.log_handle);
+ }
+
+ modify_headers(ctx, txnp, config);
+
+ return TS_SUCCESS;
+}
+
+int
+handle_http_txn_close(void *edata, PluginConfig &config)
+{
+ TSHttpTxn txnp = static_cast<TSHttpTxn>(edata);
+
+ delete get_user_arg(txnp, config);
+ set_user_arg(txnp, config, nullptr);
+
+ TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+ return TS_SUCCESS;
+}
+
+int
+handle_vconn_close(void *edata, PluginConfig &config)
+{
+ TSVConn vconn = static_cast<TSVConn>(edata);
+
+ delete get_user_arg(vconn, config);
+ set_user_arg(vconn, config, nullptr);
+
+ TSVConnReenable(vconn);
+ return TS_SUCCESS;
+}
+
+int
+main_handler(TSCont cont, TSEvent event, void *edata)
+{
+ int ret;
+
+ auto config = static_cast<PluginConfig *>(TSContDataGet(cont));
+ if (config == nullptr) {
+ // Null config means this continuation is no longer needed.
+ if (event == TS_EVENT_SSL_CLIENT_HELLO || event == TS_EVENT_VCONN_CLOSE) {
+ TSVConnReenable(static_cast<TSVConn>(edata));
+ } else {
+ TSHttpTxnReenable(static_cast<TSHttpTxn>(edata), TS_EVENT_HTTP_CONTINUE);
+ }
+ return TS_SUCCESS;
+ }
+
+ switch (event) {
+ case TS_EVENT_SSL_CLIENT_HELLO:
+ ret = handle_client_hello(edata, *config);
+ break;
+ case TS_EVENT_HTTP_READ_REQUEST_HDR:
+ ret = handle_read_request_hdr(edata, *config);
+ TSHttpTxnReenable(static_cast<TSHttpTxn>(edata), TS_EVENT_HTTP_CONTINUE);
+ break;
+ case TS_EVENT_HTTP_TXN_CLOSE:
+ ret = handle_http_txn_close(edata, *config);
+ break;
+ case TS_EVENT_VCONN_CLOSE:
+ ret = handle_vconn_close(edata, *config);
+ break;
+ default:
+ Dbg(dbg_ctl, "Unexpected event %d.", event);
+ // We ignore the event, but we don't want to reject the connection.
+ ret = TS_SUCCESS;
+ }
+
+ return ret;
+}
+
+void
+TSPluginInit(int argc, char const **argv)
+{
+ TSPluginRegistrationInfo info;
+ info.plugin_name = PLUGIN_NAME;
+ info.vendor_name = PLUGIN_VENDOR;
+ info.support_email = PLUGIN_SUPPORT_EMAIL;
+
+ if (TS_SUCCESS != TSPluginRegister(&info)) {
+ TSError("[%s] Failed to register.", PLUGIN_NAME);
+ return;
+ }
+
+ PluginConfig *config = new PluginConfig();
+ config->plugin_type = PluginType::GLOBAL;
+
+ if (!read_config_option(argc, argv, *config)) {
+ TSError("[%s] Failed to parse options.", PLUGIN_NAME);
+ return;
+ }
+
+ if (!config->log_filename.empty()) {
+ if (!create_log_file(config->log_filename, config->log_handle)) {
+ TSError("[%s] Failed to create log.", PLUGIN_NAME);
+ return;
+ } else {
+ Dbg(dbg_ctl, "Created log file.");
+ }
+ }
+
+ if (reserve_user_arg(*config) == TS_ERROR) {
+ TSError("[%s] Failed to reserve user arg index.", PLUGIN_NAME);
+ return;
+ }
+
+ TSCont cont = TSContCreate(main_handler, nullptr);
+ TSContDataSet(cont, config);
+ if (config->method.on_client_hello) {
+ TSHttpHookAdd(TS_SSL_CLIENT_HELLO_HOOK, cont);
+ }
+ if (config->standalone) {
+ TSHttpHookAdd(TS_HTTP_READ_REQUEST_HDR_HOOK, cont);
+ }
+ if (config->method.type == Method::Type::CONNECTION_BASED) {
+ TSHttpHookAdd(TS_VCONN_CLOSE_HOOK, cont);
+ } else {
+ TSHttpHookAdd(TS_HTTP_TXN_CLOSE_HOOK, cont);
+ }
+}
+
+TSReturnCode
+TSRemapInit(TSRemapInterface *api_info, char *errbuf, int errbuf_size)
+{
+ Dbg(dbg_ctl, "JAx Remap Plugin initializing..");
+ CHECK_REMAP_API_COMPATIBILITY(api_info, errbuf, errbuf_size);
+
+ return TS_SUCCESS;
+}
+
+TSReturnCode
+TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSED */, int /* errbuf_size ATS_UNUSED */)
+{
+ Dbg(dbg_ctl, "New instance for client matching %s to %s", argv[0], argv[1]);
+ auto config = new PluginConfig();
+ config->plugin_type = PluginType::REMAP;
+
+ // Parse parameters
+ if (!read_config_option(argc - 1, const_cast<const char **>(argv + 1), *config)) {
+ delete config;
+ Dbg(dbg_ctl, "Bad arguments");
+ return TS_ERROR;
+ }
+
+ // Create a log file
+ if (!config->log_filename.empty()) {
+ if (!create_log_file(config->log_filename, config->log_handle)) {
+ TSError("[%s] Failed to create log.", PLUGIN_NAME);
+ return TS_ERROR;
+ } else {
+ Dbg(dbg_ctl, "Created log file.");
+ }
+ }
+
+ if (reserve_user_arg(*config) == TS_ERROR) {
+ TSError("[%s] Failed to reserve user arg index.", PLUGIN_NAME);
+ return TS_ERROR;
+ }
+
+ // Create continuation
+ if (config->standalone) {
+ Dbg(dbg_ctl, "Standalone mode. Adding hooks.");
+ config->handler = TSContCreate(main_handler, nullptr);
+ if (config->method.on_client_hello) {
+ TSHttpHookAdd(TS_SSL_CLIENT_HELLO_HOOK, config->handler);
+ }
+ if (config->method.type == Method::Type::CONNECTION_BASED) {
+ TSHttpHookAdd(TS_VCONN_CLOSE_HOOK, config->handler);
+ } else {
+ TSHttpHookAdd(TS_HTTP_TXN_CLOSE_HOOK, config->handler);
+ }
+ TSContDataSet(config->handler, config);
+ }
+
+ *ih = static_cast<void *>(config);
+
+ return TS_SUCCESS;
+}
+
+TSRemapStatus
+TSRemapDoRemap(void *ih, TSHttpTxn rh, TSRemapRequestInfo *rri)
+{
+ auto config = static_cast<PluginConfig *>(ih);
+
+ if (!config || !rri) {
+ TSError("[%s] Invalid private data or RRI or handler.", PLUGIN_NAME);
+ return TSREMAP_NO_REMAP;
+ }
+
+ handle_read_request_hdr(rh, *config);
+
+ return TSREMAP_NO_REMAP;
+}
+
+void
+TSRemapDeleteInstance(void *ih)
+{
+ auto config = static_cast<PluginConfig *>(ih);
+ if (config->handler) {
+ // Destroying the continuation here causes a crash after remap.config reload
+ // Instead of destroying, make it NOP.
+ // TSContDestroy(config->handler);
+ TSContDataSet(config->handler, nullptr);
+ }
+ if (config->log_handle) {
+ flush_log_file(config->log_handle);
+ }
+ delete config;
+}
diff --git a/plugins/experimental/jax_fingerprint/plugin.h b/plugins/experimental/jax_fingerprint/plugin.h
new file mode 100644
index 0000000000..e2bddda4ee
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/plugin.h
@@ -0,0 +1,31 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include "ts/ts.h"
+
+constexpr char const *PLUGIN_NAME{"jax_fingerprint"};
+constexpr char const *PLUGIN_VENDOR{"Apache Software Foundation"};
+constexpr char const *PLUGIN_SUPPORT_EMAIL{"dev@trafficserver.apache.org"};
+
+extern DbgCtl dbg_ctl;
diff --git a/plugins/experimental/jax_fingerprint/userarg.cc b/plugins/experimental/jax_fingerprint/userarg.cc
new file mode 100644
index 0000000000..f3e47c16b6
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/userarg.cc
@@ -0,0 +1,53 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#include "plugin.h"
+#include "config.h"
+#include "userarg.h"
+
+int
+reserve_user_arg(PluginConfig &config)
+{
+ std::string name = PLUGIN_NAME;
+ name += config.method.name;
+
+ TSUserArgType type;
+ if (config.method.type == Method::Type::CONNECTION_BASED) {
+ type = TS_USER_ARGS_VCONN;
+ } else {
+ type = TS_USER_ARGS_TXN;
+ }
+ int ret = TSUserArgIndexReserve(type, name.c_str(), "used to pass JAx context between hooks", &config.user_arg_index);
+ Dbg(dbg_ctl, "user_arg_name: %s, user_arg_index: %d", name.c_str(), config.user_arg_index);
+ return ret;
+}
+
+void
+set_user_arg(void *container, PluginConfig &config, JAxContext *ctx)
+{
+ TSUserArgSet(container, config.user_arg_index, static_cast<void *>(ctx));
+}
+
+JAxContext *
+get_user_arg(void *container, PluginConfig &config)
+{
+ return static_cast<JAxContext *>(TSUserArgGet(container, config.user_arg_index));
+}
diff --git a/plugins/experimental/jax_fingerprint/userarg.h b/plugins/experimental/jax_fingerprint/userarg.h
new file mode 100644
index 0000000000..3e4d91d7ad
--- /dev/null
+++ b/plugins/experimental/jax_fingerprint/userarg.h
@@ -0,0 +1,32 @@
+/** @file
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#pragma once
+
+#include "context.h"
+#include "config.h"
+
+#include "ts/ts.h"
+
+int reserve_user_arg(PluginConfig &config);
+void set_user_arg(void *container, PluginConfig &config, JAxContext *ctx);
+JAxContext *get_user_arg(void *container, PluginConfig &config);
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint.test.py b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint.test.py
new file mode 100644
index 0000000000..1240670b01
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint.test.py
@@ -0,0 +1,339 @@
+'''
+Verify the behavior of the jax_fingerprint plugin.
+
+Covers all supported run modes (overwrite, keep, append), setup types
+(global, remap, hybrid), fingerprint methods (JA3, JA4, JA4H), and both
+HTTP/1.1 and HTTP/2 client connections.
+
+Global setup: plugin in plugin.config, applies to every request.
+Remap setup: plugin per remap rule, applies only to matched routes.
+Hybrid setup: global plugin captures the TLS client hello (creates
+ context) while a remap plugin reads that context and
+ sets headers only on matched routes.
+'''
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import re
+
+Test.Summary = __doc__
+Test.SkipUnless(Condition.PluginExists('jax_fingerprint.so'))
+
+
+class JaxFingerprintTest:
+ '''Verify the behavior of the jax_fingerprint plugin.'''
+
+ _dns_counter: int = 0
+ _server_counter: int = 0
+ _ts_counter: int = 0
+ _client_counter: int = 0
+
+ def __init__(
+ self, name: str, method: str, setup: str, mode: str = 'overwrite', http2: bool = False, servernames: str = '') -> None:
+ '''Configure test processes for the jax_fingerprint plugin.
+
+ :param name: Descriptive name for this test run.
+ :param method: Fingerprint method: 'JA3', 'JA4', or 'JA4H'.
+ :param setup: Plugin setup type: 'global', 'remap', or 'hybrid'.
+ :param mode: Header write mode: 'overwrite', 'keep', or 'append'.
+ :param http2: If True, the client connects to ATS over HTTP/2 (h2
+ over TLS). For JA3/JA4 the h2 ALPN in the ClientHello produces
+ a fingerprint distinct from the HTTP/1.1 case. For JA4H,
+ get_version() detects HTTP/2 via the protocol stack.
+ :param servernames: Comma-separated SNI allowlist passed as
+ --servernames to the plugin. Connections whose SNI is not in
+ the list are skipped entirely: handle_client_hello returns early,
+ no context is created, and handle_read_request_hdr is a no-op.
+ Only meaningful for CONNECTION_BASED methods (JA3/JA4) in global
+ setup.
+
+ Method notes:
+ - JA3 / JA4 are CONNECTION_BASED (triggered on TLS client hello)
+ and require TLS between the client and ATS.
+ - JA4H is REQUEST_BASED (triggered on HTTP request read) and
+ works over plain HTTP, HTTPS, and HTTP/2.
+
+ Setup notes:
+ - global: plugin.config entry with --standalone so ATS registers
+ the READ_REQUEST_HDR hook and modifies every request.
+ - remap: @plugin in remap.config. For CONNECTION_BASED methods
+ --standalone must also be passed so the remap plugin registers
+ the SSL_CLIENT_HELLO_HOOK globally and can populate the vconn
+ context that TSRemapDoRemap reads later.
+ - hybrid: global plugin (no --standalone) captures the TLS client
+ hello and stores context on the vconn; a remap plugin (no
+ --standalone) reads that shared context and sets headers only
+ on matched routes. Both instances share the same user-arg slot
+ because TSUserArgIndexReserve is idempotent for identical names.
+ '''
+ self._name = name
+ self._method = method
+ self._setup = setup
+ self._mode = mode
+ self._http2 = http2
+ self._servernames = servernames
+ # HTTP/2 always runs over TLS (h2 requires TLS).
+ self._needs_tls = method in ('JA3', 'JA4') or http2
+ self._replay_file = self._choose_replay_file()
+
+ tr = Test.AddTestRun(name)
+ self._configure_dns(tr)
+ self._configure_server(tr)
+ self._configure_trafficserver()
+ self._configure_client(tr)
+ Test.AddAwaitFileContainsTestRun(
+ f'Await jax_fingerprint.log for: {self._name}', self._ts.Disk.jax_log.AbsPath, self._method)
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _choose_replay_file(self) -> str:
+ '''Return the replay YAML to drive this test.
+
+ Key: (servernames, setup, needs_tls, http2, mode)
+ '''
+ key = (bool(self._servernames), self._setup, self._needs_tls, self._http2, self._mode)
+ mapping = {
+ (False, 'global', False, False, 'overwrite'): 'jax_fingerprint_global.replay.yaml',
+ (False, 'global', False, False, 'keep'): 'jax_fingerprint_keep.replay.yaml',
+ (False, 'global', False, False, 'append'): 'jax_fingerprint_append.replay.yaml',
+ (False, 'global', True, False, 'overwrite'): 'jax_fingerprint_tls.replay.yaml',
+ (False, 'global', True, True, 'overwrite'): 'jax_fingerprint_global_h2.replay.yaml',
+ (False, 'remap', False, False, 'overwrite'): 'jax_fingerprint_remap.replay.yaml',
+ (False, 'remap', True, False, 'overwrite'): 'jax_fingerprint_remap_tls.replay.yaml',
+ (False, 'hybrid', True, False, 'overwrite'): 'jax_fingerprint_remap_tls.replay.yaml',
+ (True, 'global', True, False, 'overwrite'): 'jax_fingerprint_servernames.replay.yaml',
+ (True, 'hybrid', True, False, 'overwrite'): 'jax_fingerprint_hybrid_servernames.replay.yaml',
+ }
+ return mapping[key]
+
+ def _build_remap_plugin_line(self, add_standalone: bool = False) -> str:
+ '''Build the @plugin / @pparam fragment for a remap.config line.'''
+ parts = [
+ '@plugin=jax_fingerprint.so',
+ '@pparam=--method',
+ f'@pparam={self._method}',
+ '@pparam=--header',
+ '@pparam=x-jax',
+ '@pparam=--via-header',
+ '@pparam=x-jax-via',
+ '@pparam=--log-filename',
+ '@pparam=jax_fingerprint',
+ ]
+ if self._mode != 'overwrite':
+ parts.extend(['@pparam=--mode', f'@pparam={self._mode}'])
+ if add_standalone:
+ parts.append('@pparam=--standalone')
+ return ' '.join(parts)
+
+ # ------------------------------------------------------------------
+ # Test-process configuration
+ # ------------------------------------------------------------------
+
+ def _configure_dns(self, tr: 'TestRun') -> None:
+ '''Configure a nameserver for the test.'''
+ name = f'dns{JaxFingerprintTest._dns_counter}'
+ self._dns = tr.MakeDNServer(name, default='127.0.0.1')
+ JaxFingerprintTest._dns_counter += 1
+
+ def _configure_server(self, tr: 'TestRun') -> None:
+ '''Configure the origin (verifier) server.'''
+ name = f'server{JaxFingerprintTest._server_counter}'
+ self._server = tr.AddVerifierServerProcess(name, self._replay_file)
+ JaxFingerprintTest._server_counter += 1
+
+ # For remap / hybrid tests the second session reaches the server
+ # with the fingerprint header already appended by ATS; verify it.
+ if self._servernames:
+ # Only the SNI-matched session carries the fingerprint header.
+ uuid_key = 'hybrid-servernames-in-filter' if self._setup == 'hybrid' else 'servernames-in-filter'
+ self._server.Streams.All += Testers.ContainsExpression(uuid_key, 'Verify the allowed-SNI request reached the server.')
+ self._server.Streams.All += Testers.ContainsExpression(
+ r'x-jax:', 'Verify the fingerprint header was forwarded for the allowed SNI.', reflags=re.IGNORECASE)
+ elif self._setup in ('remap', 'hybrid'):
+ uuid_key = 'remap-tls-plugin-request' if self._needs_tls else 'remap-plugin-request'
+ self._server.Streams.All += Testers.ContainsExpression(uuid_key, 'Verify the matched-route request reached the server.')
+ self._server.Streams.All += Testers.ContainsExpression(
+ r'x-jax:', 'Verify the fingerprint header was forwarded.', reflags=re.IGNORECASE)
+ else:
+ # global tests - all requests carry the fingerprint header
+ self._server.Streams.All += Testers.ContainsExpression(
+ r'x-jax:', 'Verify the fingerprint header was forwarded.', reflags=re.IGNORECASE)
+
+ def _configure_trafficserver(self) -> None:
+ '''Configure Traffic Server and its plugin / remap rules.'''
+ name = f'ts{JaxFingerprintTest._ts_counter}'
+ self._ts = Test.MakeATSProcess(name, enable_cache=False, enable_tls=True)
+ JaxFingerprintTest._ts_counter += 1
+
+ self._ts.addDefaultSSLFiles()
+ self._ts.Disk.ssl_multicert_yaml.AddLines(
+ """
+ssl_multicert:
+ - dest_ip: "*"
+ ssl_cert_name: server.pem
+ ssl_key_name: server.key
+""".split("\n"))
+
+ if self._needs_tls:
+ server_port = self._server.Variables.https_port
+ scheme = 'https'
+ else:
+ server_port = self._server.Variables.http_port
+ scheme = 'http'
+
+ self._ts.Disk.records_config.update(
+ {
+ 'proxy.config.ssl.server.cert.path': self._ts.Variables.SSLDir,
+ 'proxy.config.ssl.server.private_key.path': self._ts.Variables.SSLDir,
+ 'proxy.config.ssl.client.verify.server.policy': 'PERMISSIVE',
+ 'proxy.config.dns.nameservers': f"127.0.0.1:{self._dns.Variables.Port}",
+ 'proxy.config.dns.resolv_conf': 'NULL',
+ 'proxy.config.proxy_name': 'test.proxy.test',
+ 'proxy.config.diags.debug.enabled': 1,
+ 'proxy.config.diags.debug.tags': 'jax_fingerprint|http',
+ })
+
+ log_path = os.path.join(self._ts.Variables.LOGDIR, 'jax_fingerprint.log')
+ self._ts.Disk.File(log_path, id='jax_log')
+ self._ts.Disk.jax_log.Content += Testers.ContainsExpression(
+ rf'.*{self._method}:.*', f'Verify the log contains a {self._method} fingerprint.', reflags=re.MULTILINE)
+
+ backend = f'{scheme}://jax.backend.test:{server_port}'
+ backend_no_plugin = f'{scheme}://jax.backend.test:{server_port}'
+
+ if self._setup == 'global':
+ global_args = (
+ f'--method {self._method} '
+ f'--header x-jax '
+ f'--via-header x-jax-via '
+ f'--log-filename jax_fingerprint '
+ f'--standalone')
+ if self._mode != 'overwrite':
+ global_args += f' --mode {self._mode}'
+ if self._servernames:
+ global_args += f' --servernames {self._servernames}'
+ self._ts.Disk.plugin_config.AddLine(f'jax_fingerprint.so {global_args}')
+ self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax.server.test {backend}')
+ if self._servernames:
+ # Second remap rule for the SNI that is NOT in the allowlist.
+ self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax-filtered.server.test {backend}')
+
+ elif self._setup == 'remap':
+ # Route without plugin (session 1 in replay file)
+ self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax-no-plugin.server.test {backend_no_plugin}')
+ # Route with plugin (session 2 in replay file)
+ # CONNECTION_BASED methods need --standalone so the remap plugin
+ # registers the SSL_CLIENT_HELLO_HOOK to populate the vconn context.
+ remap_line = self._build_remap_plugin_line(add_standalone=self._needs_tls)
+ self._ts.Disk.remap_config.AddLine(f'map {scheme}://jax.server.test {backend} {remap_line}')
+
+ elif self._setup == 'hybrid':
+ # Global plugin: registers SSL_CLIENT_HELLO_HOOK to capture the
+ # TLS handshake and store context on the vconn. No --standalone
+ # means no READ_REQUEST_HDR hook, so headers are never set here.
+ global_plugin_args = f'--method {self._method}'
+ if self._servernames:
+ global_plugin_args += f' --servernames {self._servernames}'
+ self._ts.Disk.plugin_config.AddLine(f'jax_fingerprint.so {global_plugin_args}')
+ remap_line = self._build_remap_plugin_line(add_standalone=False)
+ if self._servernames:
+ # Both routes have the remap plugin. Only the SNI-allowed
+ # connection has a context on the vconn, so only that request
+ # gets fingerprint headers even though both routes are mapped.
+ self._ts.Disk.remap_config.AddLine(
+ f'map https://jax.server.test https://jax.backend.test:{server_port} {remap_line}')
+ self._ts.Disk.remap_config.AddLine(
+ f'map https://jax-filtered.server.test https://jax.backend.test:{server_port} {remap_line}')
+ else:
+ # Route without remap plugin: context is captured but no headers set.
+ self._ts.Disk.remap_config.AddLine(f'map https://jax-no-plugin.server.test https://jax.backend.test:{server_port}')
+ # Route with remap plugin: reads shared vconn context, sets headers.
+ self._ts.Disk.remap_config.AddLine(
+ f'map https://jax.server.test https://jax.backend.test:{server_port} {remap_line}')
+
+ def _configure_client(self, tr: 'TestRun') -> None:
+ '''Configure the verifier client.'''
+ name = f'client{JaxFingerprintTest._client_counter}'
+ p = tr.AddVerifierClientProcess(
+ name, self._replay_file, http_ports=[self._ts.Variables.port], https_ports=[self._ts.Variables.ssl_port])
+ JaxFingerprintTest._client_counter += 1
+
+ p.StartBefore(self._dns)
+ p.StartBefore(self._server)
+ p.StartBefore(self._ts)
+ tr.StillRunningAfter = self._ts
+
+
+# ======================================================================
+# Test instances
+# ======================================================================
+
+# --- Global setup -------------------------------------------------------
+
+# All HTTP/1.1 requests receive a JA4H fingerprint header (overwrite mode).
+JaxFingerprintTest('Global JA4H overwrite', 'JA4H', 'global')
+
+# All TLS requests receive a JA3 fingerprint header (overwrite mode).
+JaxFingerprintTest('Global JA3 overwrite', 'JA3', 'global')
+
+# --- Remap setup --------------------------------------------------------
+
+# Only requests matching the remap rule receive a JA4H header.
+JaxFingerprintTest('Remap JA4H', 'JA4H', 'remap')
+
+# Remap plugin with --standalone captures TLS client hellos globally and
+# sets JA3 headers only on the matched route.
+JaxFingerprintTest('Remap JA3 standalone', 'JA3', 'remap')
+
+# --- Hybrid setup -------------------------------------------------------
+
+# Global plugin captures TLS client hellos (creates vconn context).
+# Remap plugin reads that shared context and sets headers on matched routes.
+JaxFingerprintTest('Hybrid JA4', 'JA4', 'hybrid')
+
+# --- HTTP/2 (h2 over TLS) -----------------------------------------------
+
+# JA4: the h2 ALPN in the ClientHello produces a different fingerprint than
+# the HTTP/1.1 case – exercises the full TLS extension path with h2 ALPN.
+JaxFingerprintTest('Global JA4 HTTP/2', 'JA4', 'global', http2=True)
+
+# JA4H: exercises the HTTP/2 branch of get_version() which detects h2 via
+# TSHttpTxnClientProtocolStackContains.
+JaxFingerprintTest('Global JA4H HTTP/2', 'JA4H', 'global', http2=True)
+
+# keep mode: existing x-jax / x-jax-via headers are not overwritten.
+JaxFingerprintTest('Global JA4H keep mode', 'JA4H', 'global', mode='keep')
+
+# append mode: fingerprint / proxy name are appended to existing header values.
+JaxFingerprintTest('Global JA4H append mode', 'JA4H', 'global', mode='append')
+
+# --- SNI allowlist (--servernames) --------------------------------------
+
+# --servernames restricts fingerprinting to connections whose TLS SNI is in
+# the list. Connections with a non-matching SNI are skipped at the client-
+# hello hook: no context is created and handle_read_request_hdr is a no-op,
+# so neither the fingerprint header nor the via header are set.
+JaxFingerprintTest('Global JA4 servernames', 'JA4', 'global', servernames='jax.server.test')
+
+# --- Hybrid + SNI allowlist (most common production pattern) ---------------
+
+# Global plugin captures TLS client hellos for allowed SNIs only.
+# Remap plugin sets headers on both routes, but only the SNI-allowed
+# connection has a vconn context, so only that request gets headers.
+JaxFingerprintTest('Hybrid JA4 servernames', 'JA4', 'hybrid', servernames='jax.server.test')
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_append.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_append.replay.yaml
new file mode 100644
index 0000000000..3381826bc9
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_append.replay.yaml
@@ -0,0 +1,114 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: global JA4H append mode test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA4H
+# --standalone --mode append.
+#
+# append mode behaviour (from header.cc):
+# - If the header field is absent: create it with the new value.
+# - If the header field is already present: insert the new value as an
+# additional value in the last duplicate field. HTTP clients and
+# intermediaries represent this as a comma-separated list,
+# e.g. "upstream-via, test.proxy.test".
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: no pre-existing x-jax-via – plugin creates it.
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource-no-via
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, append-no-existing-via ]
+
+ # No existing via header – plugin sets it to the proxy name.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "OK"
+
+ proxy-response:
+ status: 200
+
+# Session 2: pre-existing x-jax-via – append mode adds proxy name to it.
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource-with-via
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, append-existing-via ]
+ - [ x-jax-via, upstream-via ]
+
+ # append mode: proxy name is inserted as an additional value, yielding
+ # the comma-separated form "upstream-via, test.proxy.test".
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'upstream-via, test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "OK"
+
+ proxy-response:
+ status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global.replay.yaml
new file mode 100644
index 0000000000..4a748d3e11
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global.replay.yaml
@@ -0,0 +1,111 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: global JA4H overwrite test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA4H
+# --standalone --mode overwrite. Every request should have the
+# fingerprint header (x-jax) and via header (x-jax-via) written by ATS
+# regardless of any pre-existing values.
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: no pre-existing x-jax header – plugin adds it.
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, global-ja4h-request ]
+
+ # Global plugin (overwrite) must add both headers.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "OK"
+
+ proxy-response:
+ status: 200
+
+# Session 2: pre-existing x-jax header – overwrite mode replaces it.
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource-with-header
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, global-ja4h-overwrite ]
+ - [ x-jax, upstream-fingerprint ]
+ - [ x-jax-via, upstream-via ]
+
+ # In overwrite mode the plugin replaces the upstream values.
+ # x-jax contains the freshly computed fingerprint (dynamic value,
+ # so we only assert presence); x-jax-via is set to the proxy name.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "OK"
+
+ proxy-response:
+ status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global_h2.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global_h2.replay.yaml
new file mode 100644
index 0000000000..509416d843
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_global_h2.replay.yaml
@@ -0,0 +1,121 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: global JA4 HTTP/2 test and global JA4H HTTP/2 test.
+#
+# The client connects to ATS over HTTP/2 (h2 over TLS). ATS forwards
+# to the backend over HTTPS/1.1.
+#
+# JA3/JA4: the TLS ClientHello includes an h2 ALPN extension, so the
+# computed fingerprint differs from a plain HTTP/1.1 TLS session.
+#
+# JA4H: TxnDatasource::get_version() detects HTTP/2 via
+# TSHttpTxnClientProtocolStackContains(txn, "h2"), producing an
+# h2-specific fingerprint value.
+#
+# In both cases the plugin writes x-jax and x-jax-via into the
+# HTTP/1.1 proxy-request that ATS sends upstream. Header field names
+# are already lowercase so HTTP/2's mandatory-lowercase rule does not
+# introduce any new behaviour to verify here.
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: HTTP/2 request without pre-existing x-jax – plugin adds it.
+- protocol:
+ - name: http
+ version: 2
+ - name: tls
+ sni: jax.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ headers:
+ fields:
+ - [ :method, GET ]
+ - [ :scheme, https ]
+ - [ :authority, jax.server.test ]
+ - [ :path, /resource-h2 ]
+ - [ uuid, global-h2-request ]
+
+ # ATS translates to HTTP/1.1 toward the backend; the plugin must have
+ # written both headers into this outgoing request.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ headers:
+ fields:
+ - [ :status, 200 ]
+ - [ Content-Length, 2 ]
+ - [ X-Response, global-h2-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ headers:
+ fields:
+ - [ :status, { value: 200, as: equal } ]
+
+# Session 2: HTTP/2 request with pre-existing x-jax – overwrite mode
+# replaces the upstream value with the freshly computed fingerprint.
+- protocol:
+ - name: http
+ version: 2
+ - name: tls
+ sni: jax.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ headers:
+ fields:
+ - [ :method, GET ]
+ - [ :scheme, https ]
+ - [ :authority, jax.server.test ]
+ - [ :path, /resource-h2-with-header ]
+ - [ uuid, global-h2-overwrite ]
+ - [ x-jax, upstream-fingerprint ]
+ - [ x-jax-via, upstream-via ]
+
+ # Overwrite mode: both upstream values must be replaced by the plugin.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ headers:
+ fields:
+ - [ :status, 200 ]
+ - [ Content-Length, 2 ]
+ - [ X-Response, global-h2-overwrite-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ headers:
+ fields:
+ - [ :status, { value: 200, as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_hybrid_servernames.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_hybrid_servernames.replay.yaml
new file mode 100644
index 0000000000..2d1e53cdbf
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_hybrid_servernames.replay.yaml
@@ -0,0 +1,124 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: hybrid JA4 servernames test.
+#
+# The global plugin (no --standalone) is loaded with --method JA4
+# --servernames jax.server.test. It registers SSL_CLIENT_HELLO_HOOK
+# and, for connections whose SNI is in the allowlist, stores a JAxContext
+# on the vconn user-arg slot. For connections whose SNI is NOT in the
+# allowlist it calls TSVConnReenable and returns immediately -- no context
+# is ever created.
+#
+# Both remap rules carry the remap plugin (no --standalone). TSRemapDoRemap
+# calls handle_read_request_hdr which looks for a JAxContext on the vconn:
+# - jax.server.test (SNI in allowlist) : context present → headers added
+# - jax-filtered.server.test (SNI absent) : context absent → headers absent
+#
+# This is the most common production pattern: a single global instance
+# captures TLS client hellos while per-route remap plugins control which
+# responses actually get fingerprint headers.
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: SNI matches the allowlist – global plugin creates context,
+# remap plugin reads it and sets fingerprint headers.
+- protocol:
+ - name: tls
+ sni: jax.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, hybrid-servernames-in-filter ]
+
+ # SNI is in the allowlist – remap plugin must have set both headers.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 10 ]
+ - [ X-Response, hybrid-sni-allowed-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'hybrid-sni-allowed-response', as: equal } ]
+
+# Session 2: SNI does NOT match the allowlist – global plugin skips the
+# connection (no context on vconn). Even though the remap rule carries the
+# plugin, handle_read_request_hdr finds no context and returns without
+# touching any headers.
+- protocol:
+ - name: tls
+ sni: jax-filtered.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax-filtered.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, hybrid-servernames-filtered ]
+
+ # SNI is not in the allowlist – neither header must appear.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: absent } ]
+ - [ x-jax-via, { as: absent } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 12 ]
+ - [ X-Response, hybrid-sni-filtered-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'hybrid-sni-filtered-response', as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_keep.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_keep.replay.yaml
new file mode 100644
index 0000000000..c3b4ff9bf8
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_keep.replay.yaml
@@ -0,0 +1,111 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: global JA4H keep mode test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA4H
+# --standalone --mode keep.
+#
+# keep mode: if the fingerprint header (x-jax) or via header (x-jax-via)
+# is already present on the incoming request, the plugin must leave them
+# unchanged. If they are absent the plugin sets them as usual.
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: no pre-existing headers – plugin adds them (same as overwrite).
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource-no-header
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, keep-no-existing-header ]
+
+ # Headers were absent – plugin must set them.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "OK"
+
+ proxy-response:
+ status: 200
+
+# Session 2: pre-existing headers present – keep mode must preserve them.
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource-with-header
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, keep-existing-header ]
+ - [ x-jax, upstream-fingerprint ]
+ - [ x-jax-via, upstream-via ]
+
+ # keep mode: original upstream values must be forwarded unchanged.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { value: 'upstream-fingerprint', as: equal } ]
+ - [ x-jax-via, { value: 'upstream-via', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "OK"
+
+ proxy-response:
+ status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap.replay.yaml
new file mode 100644
index 0000000000..8fc04a3319
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap.replay.yaml
@@ -0,0 +1,109 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: remap JA4H test.
+#
+# Two HTTP sessions reach the same origin server, but through different
+# remap rules:
+# jax-no-plugin.server.test – no jax_fingerprint plugin configured
+# jax.server.test – jax_fingerprint plugin with JA4H method
+#
+# The plugin must add fingerprint headers only for the second route.
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: request to the route without the plugin – no fingerprint headers.
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax-no-plugin.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, remap-no-plugin-request ]
+
+ # No plugin on this route – headers must be absent.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: absent } ]
+ - [ x-jax-via, { as: absent } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 9 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "No plugin"
+
+ proxy-response:
+ status: 200
+
+# Session 2: request to the route with the plugin – fingerprint headers added.
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, remap-plugin-request ]
+
+ # Plugin is active for this route – both headers must be present.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 11 ]
+ - [ Connection, close ]
+ content:
+ encoding: plain
+ data: "With plugin"
+
+ proxy-response:
+ status: 200
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap_tls.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap_tls.replay.yaml
new file mode 100644
index 0000000000..9b21a10b77
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_remap_tls.replay.yaml
@@ -0,0 +1,120 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: remap JA3 standalone test AND hybrid JA4 test.
+#
+# Two TLS sessions reach the same origin server through different remap rules:
+# jax-no-plugin.server.test – no jax_fingerprint plugin for this route
+# jax.server.test – jax_fingerprint plugin active for this route
+#
+# For the remap+standalone test: the remap plugin registers the
+# SSL_CLIENT_HELLO_HOOK globally (--standalone) so it can populate the
+# vconn context that TSRemapDoRemap later reads.
+#
+# For the hybrid test: a global plugin (no --standalone) captures the TLS
+# client hello and stores context on the vconn; a remap plugin (no
+# --standalone) reads that shared context and writes headers only for the
+# matched route.
+#
+# In both cases the first session must have no fingerprint headers and the
+# second session must have them.
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: TLS request to route without plugin – no fingerprint headers.
+- protocol:
+ - name: tls
+ sni: jax-no-plugin.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax-no-plugin.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, remap-tls-no-plugin-request ]
+
+ # No plugin on this route – headers must be absent.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: absent } ]
+ - [ x-jax-via, { as: absent } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 9 ]
+ - [ X-Response, remap-tls-no-plugin-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'remap-tls-no-plugin-response', as: equal } ]
+
+# Session 2: TLS request to route with plugin – fingerprint headers added.
+- protocol:
+ - name: tls
+ sni: jax.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, remap-tls-plugin-request ]
+
+ # Plugin is active for this route – both headers must be present.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 11 ]
+ - [ X-Response, remap-tls-plugin-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'remap-tls-plugin-response', as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_servernames.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_servernames.replay.yaml
new file mode 100644
index 0000000000..994dde2266
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_servernames.replay.yaml
@@ -0,0 +1,114 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: global JA3 servernames test.
+#
+# The plugin is loaded globally with --servernames jax.server.test.
+# In handle_client_hello, if the incoming SNI is not in the allowlist the
+# plugin calls TSVConnReenable and returns immediately. No JAxContext is
+# ever created for that vconn, so handle_read_request_hdr returns early
+# ("No context. Skipping.") without touching any headers.
+#
+# Two TLS sessions reach the same backend through different remap rules:
+# jax.server.test – SNI is IN the allowlist → headers added
+# jax-filtered.server.test – SNI NOT in the allowlist → headers absent
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: SNI matches the allowlist – fingerprint headers must be added.
+- protocol:
+ - name: tls
+ sni: jax.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, servernames-in-filter ]
+
+ # SNI is in the allowlist – plugin must have computed and set both headers.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 10 ]
+ - [ X-Response, in-filter-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'in-filter-response', as: equal } ]
+
+# Session 2: SNI does NOT match the allowlist – plugin skips the connection.
+- protocol:
+ - name: tls
+ sni: jax-filtered.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax-filtered.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, servernames-filtered ]
+
+ # SNI is not in the allowlist – plugin must not have touched either header.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: absent } ]
+ - [ x-jax-via, { as: absent } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 12 ]
+ - [ X-Response, filtered-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'filtered-response', as: equal } ]
diff --git a/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_tls.replay.yaml b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_tls.replay.yaml
new file mode 100644
index 0000000000..5bedf633c0
--- /dev/null
+++ b/tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint_tls.replay.yaml
@@ -0,0 +1,111 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Used by: global JA3 overwrite test.
+#
+# The jax_fingerprint plugin is loaded globally with --method JA3
+# --standalone --mode overwrite. TLS/HTTPS requests should have the
+# JA3 fingerprint header (x-jax) and via header (x-jax-via) set by ATS.
+
+meta:
+ version: "1.0"
+
+sessions:
+
+# Session 1: TLS/HTTPS request without pre-existing x-jax – plugin adds it.
+- protocol:
+ - name: tls
+ sni: jax.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, global-ja3-request ]
+
+ # Global plugin (overwrite) must add both headers.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ X-Response, global-ja3-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'global-ja3-response', as: equal } ]
+
+# Session 2: TLS/HTTPS request with pre-existing x-jax – overwrite mode
+# replaces the upstream fingerprint with the one computed by ATS.
+- protocol:
+ - name: tls
+ sni: jax.server.test
+ - name: tcp
+ - name: ip
+
+ transactions:
+
+ - client-request:
+ method: GET
+ url: /resource-with-header
+ version: '1.1'
+ headers:
+ fields:
+ - [ Host, jax.server.test ]
+ - [ Content-Length, 0 ]
+ - [ uuid, global-ja3-overwrite ]
+ - [ x-jax, upstream-fingerprint ]
+ - [ x-jax-via, upstream-via ]
+
+ # Overwrite mode: upstream values are replaced by the plugin.
+ proxy-request:
+ headers:
+ fields:
+ - [ x-jax, { as: present } ]
+ - [ x-jax-via, { value: 'test.proxy.test', as: equal } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 2 ]
+ - [ X-Response, global-ja3-overwrite-response ]
+ - [ Connection, close ]
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ X-Response, { value: 'global-ja3-overwrite-response', as: equal } ]