From 74a681b275447607d322357adadc03c74f78de9c Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Wed, 5 Nov 2025 20:13:53 +0100 Subject: [PATCH] ja4 fingerprinting added to upstream request Signed-off-by: Ronni Skansing --- backend/app/server.go | 18 +++++ backend/go.mod | 1 + backend/go.sum | 2 + backend/middleware/ja4.go | 131 +++++++++++++++++++++++++++++++++++++ backend/vendor/modules.txt | 3 + 5 files changed, 155 insertions(+) create mode 100644 backend/middleware/ja4.go diff --git a/backend/app/server.go b/backend/app/server.go index b7ef49a..86bdc33 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -29,6 +29,7 @@ import ( "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/middleware" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/proxy" "github.com/phishingclub/phishingclub/repository" @@ -55,6 +56,7 @@ type Server struct { services *Services repositories *Repositories proxyServer *proxy.ProxyHandler + ja4Middleware *middleware.JA4Middleware } // NewServer returns a new server @@ -68,6 +70,8 @@ func NewServer( logger *zap.SugaredLogger, certMagicConfig *certmagic.Config, ) *Server { + // setup ja4 middleware for tls fingerprinting + ja4Middleware := middleware.NewJA4Middleware(logger) // setup goproxy-based proxy server proxyServer := proxy.NewProxyHandler( logger, @@ -107,6 +111,7 @@ func NewServer( logger: logger, certMagicConfig: certMagicConfig, proxyServer: proxyServer, + ja4Middleware: ja4Middleware, } } @@ -331,6 +336,9 @@ func (s *Server) checkAndServeSharedAsset(c *gin.Context) bool { // checks if the request should be redirected // checks if the request is for a static page or static not found page func (s *Server) Handler(c *gin.Context) { + // inject ja4 fingerprint if available + s.ja4Middleware.GinHandler()(c) + // add error recovery for handler defer func() { if r := recover(); r != nil { @@ -1692,6 +1700,11 @@ func (s *Server) StartHTTPS( // setup TLS config tlsConf := s.certMagicConfig.TLSConfig() tlsConf.NextProtos = append([]string{"h2"}, tlsConf.NextProtos...) + + // configure ja4 middleware for tls fingerprinting + if s.ja4Middleware != nil { + tlsConf.GetConfigForClient = s.ja4Middleware.GetConfigForClient + } // setup gin ln, err := tls.Listen( "tcp", @@ -1702,6 +1715,11 @@ func (s *Server) StartHTTPS( return nil, nil, fmt.Errorf("failed to listen on %s due to: %s", ln.Addr().String(), err) } s.HTTPSServer = s.defaultServer(r, true) + + // register ja4 connection state callback for cleanup + if s.ja4Middleware != nil { + s.HTTPSServer.ConnState = s.ja4Middleware.ConnStateCallback + } // start server go func() { s.logger.Debugw("starting phishing HTTPS server", diff --git a/backend/go.mod b/backend/go.mod index f3e6c3f..3ba7453 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/lipgloss v1.1.0 + github.com/exaring/ja4plus v0.0.2 github.com/fatih/color v1.15.0 github.com/gin-contrib/zap v1.1.4 github.com/gin-gonic/gin v1.10.0 diff --git a/backend/go.sum b/backend/go.sum index d41d4d1..e4c92e8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -43,6 +43,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/exaring/ja4plus v0.0.2 h1:lfLUicnWFuIlAVHPaq9t0PfSC++AOt1vt+PXg3+Hz5w= +github.com/exaring/ja4plus v0.0.2/go.mod h1:W9UnA4hC2x6dL+WvwphbNDUH0FWVTHfF0p+vk0my5SY= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= diff --git a/backend/middleware/ja4.go b/backend/middleware/ja4.go new file mode 100644 index 0000000..854f0f6 --- /dev/null +++ b/backend/middleware/ja4.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "crypto/tls" + "net" + "net/http" + "sync" + "time" + + "github.com/exaring/ja4plus" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +const ( + // HeaderJA4 is the internal header key for ja4 fingerprint + HeaderJA4 = "X-JA4" + + // ContextKeyJA4 is the gin context key for ja4 fingerprint + ContextKeyJA4 = "ja4_fingerprint" +) + +type fingerprintEntry struct { + fingerprint string + lastAccess time.Time +} + +// JA4Middleware handles ja4+ fingerprinting for tls connections +type JA4Middleware struct { + connectionFingerprints sync.Map + logger *zap.SugaredLogger +} + +// NewJA4Middleware creates a new ja4 middleware instance +func NewJA4Middleware(logger *zap.SugaredLogger) *JA4Middleware { + m := &JA4Middleware{ + logger: logger, + } + + // start periodic cleanup routine to prevent memory leaks + // in case ConnState callback doesn't fire reliably + go m.periodicCleanup() + + return m +} + +// periodicCleanup removes stale fingerprint entries +func (m *JA4Middleware) periodicCleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + now := time.Now() + staleThreshold := 10 * time.Minute + count := 0 + + m.connectionFingerprints.Range(func(key, value interface{}) bool { + if entry, ok := value.(*fingerprintEntry); ok { + if now.Sub(entry.lastAccess) > staleThreshold { + m.connectionFingerprints.Delete(key) + count++ + } + } + return true + }) + + } +} + +// StoreFingerprintFromClientHello stores the ja4 fingerprint from tls clienthello +func (m *JA4Middleware) StoreFingerprintFromClientHello(hello *tls.ClientHelloInfo) { + fingerprint := ja4plus.JA4(hello) + entry := &fingerprintEntry{ + fingerprint: fingerprint, + lastAccess: time.Now(), + } + m.connectionFingerprints.Store(hello.Conn.RemoteAddr().String(), entry) +} + +// ConnStateCallback cleans up fingerprint cache when connection closes +func (m *JA4Middleware) ConnStateCallback(conn net.Conn, state http.ConnState) { + switch state { + case http.StateClosed, http.StateHijacked: + addr := conn.RemoteAddr().String() + m.connectionFingerprints.Delete(addr) + } +} + +// GinHandler returns a gin handler that injects ja4 fingerprint into context and headers +func (m *JA4Middleware) GinHandler() gin.HandlerFunc { + return func(c *gin.Context) { + // try to get fingerprint from cache + if cacheEntry, ok := m.connectionFingerprints.Load(c.Request.RemoteAddr); ok { + if entry, ok := cacheEntry.(*fingerprintEntry); ok { + fingerprint := entry.fingerprint + + // update last access time + entry.lastAccess = time.Now() + + // set as internal header for downstream use + c.Request.Header.Set(HeaderJA4, fingerprint) + + // set in gin context + c.Set(ContextKeyJA4, fingerprint) + } + } + + c.Next() + } +} + +// GetConfigForClient returns a tls.Config callback for capturing clienthello +func (m *JA4Middleware) GetConfigForClient(hello *tls.ClientHelloInfo) (*tls.Config, error) { + m.StoreFingerprintFromClientHello(hello) + return nil, nil +} + +// GetJA4FromContext extracts the ja4 fingerprint from gin context +func GetJA4FromContext(c *gin.Context) string { + if fingerprint, exists := c.Get(ContextKeyJA4); exists { + if fp, ok := fingerprint.(string); ok { + return fp + } + } + return "" +} + +// GetJA4FromHeader extracts the ja4 fingerprint from request headers +func GetJA4FromHeader(c *gin.Context) string { + return c.Request.Header.Get(HeaderJA4) +} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index 855e8b6..071a279 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -121,6 +121,9 @@ github.com/davecgh/go-spew/spew # github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f ## explicit; go 1.16 github.com/erikgeiser/coninput +# github.com/exaring/ja4plus v0.0.2 +## explicit; go 1.24 +github.com/exaring/ja4plus # github.com/fatih/color v1.15.0 ## explicit; go 1.17 github.com/fatih/color