From 6f81ec2aaf4b8c017a1d876b150cf5458c66a713 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:42:09 +0200 Subject: [PATCH] Forward Accept-Language and Sec-CH-UA* headers in proxied requests Plausible's bot detection relies on these headers, which weren't passed on for requests sent through the proxy (or server-side events). --- readme.txt | 1 + src/Proxy.php | 43 +++++++++++++++++++-- tests/integration/ProxyTest.php | 68 +++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/readme.txt b/readme.txt index b77d2f18..05c7637a 100644 --- a/readme.txt +++ b/readme.txt @@ -279,6 +279,7 @@ Please make sure you make a backup of your database before updating any version == Changelog == = 2.6.4 = +* Improved: the proxy now also forwards the visitor's Accept-Language and User-Agent Client Hints (Sec-CH-UA*) headers to Plausible Analytics, so its bot detection works for proxied requests, too. * Fixed: on sites using WPML (or another multilingual plugin) with a directory per language, the Domain Name could be pre-filled with the language directory included (e.g. example.com/fr), which made the Plugin Token show as invalid. * Fixed: for user roles that aren't tracked (administrators by default), some integrations still loaded the tracker without initializing it, which caused a "window.plausible is not a function" error, e.g. on search results, 404 and checkout pages. They no longer load anything for those roles. * Fixed: events sent from the server (WooCommerce and Easy Digital Downloads add to cart, remove from cart and EDD checkout, and Contact Form 7 and Gravity Forms submissions) were also sent for user roles that aren't tracked. diff --git a/src/Proxy.php b/src/Proxy.php index a7a6b4b5..8b0adb1d 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -162,15 +162,50 @@ public function send_event( $request ) { $url, [ 'user-agent' => $ua, - 'headers' => [ - 'X-Forwarded-For' => $ip, - 'Content-Type' => 'application/json', - ], + 'headers' => array_merge( + $this->get_forwarded_headers(), + [ + 'X-Forwarded-For' => $ip, + 'Content-Type' => 'application/json', + ] + ), 'body' => wp_kses_no_null( $params ), ] ); } + /** + * Returns the visitor's Accept-Language and User-Agent Client Hints (Sec-CH-UA*) headers, so Plausible's bot + * detection can use them for proxied requests, too. + * + * @since v2.6.4 + * + * @return array + */ + public function get_forwarded_headers() { + $headers = []; + + foreach ( $_SERVER as $key => $value ) { + if ( $key !== 'HTTP_ACCEPT_LANGUAGE' && strpos( $key, 'HTTP_SEC_CH_UA' ) !== 0 ) { + continue; + } + + $value = trim( wp_kses( wp_unslash( (string) $value ), 'strip' ) ); + + if ( $value === '' ) { + continue; + } + + // e.g. HTTP_SEC_CH_UA_PLATFORM -> Sec-CH-UA-Platform + $name = str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', substr( $key, 5 ) ) ) ) ); + $name = str_replace( 'Sec-Ch-Ua', 'Sec-CH-UA', $name ); + + $headers[ $name ] = $value; + } + + return $headers; + } + /** * @return string * diff --git a/tests/integration/ProxyTest.php b/tests/integration/ProxyTest.php index b955368b..be2c6023 100644 --- a/tests/integration/ProxyTest.php +++ b/tests/integration/ProxyTest.php @@ -26,4 +26,72 @@ public function testGenerateEventUrl() { $this->assertEquals( 'http://example.org/test', $url ); } + + /** + * @see Proxy::get_forwarded_headers() + * @return void + */ + public function testGetForwardedHeaders() { + $proxy = new Proxy( false ); + + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'nl-NL,nl;q=0.9,en;q=0.8'; + $_SERVER['HTTP_SEC_CH_UA'] = '"Chromium";v="128", "Not;A=Brand";v="24"'; + $_SERVER['HTTP_SEC_CH_UA_MOBILE'] = '?0'; + $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] = '"Windows"'; + $_SERVER['HTTP_SEC_CH_UA_FULL_VERSION_LIST'] = ''; + $_SERVER['HTTP_SEC_FETCH_SITE'] = 'same-origin'; + + try { + $this->assertEquals( + [ + 'Accept-Language' => 'nl-NL,nl;q=0.9,en;q=0.8', + 'Sec-CH-UA' => '"Chromium";v="128", "Not;A=Brand";v="24"', + 'Sec-CH-UA-Mobile' => '?0', + 'Sec-CH-UA-Platform' => '"Windows"', + ], + $proxy->get_forwarded_headers() + ); + } finally { + unset( + $_SERVER['HTTP_ACCEPT_LANGUAGE'], + $_SERVER['HTTP_SEC_CH_UA'], + $_SERVER['HTTP_SEC_CH_UA_MOBILE'], + $_SERVER['HTTP_SEC_CH_UA_PLATFORM'], + $_SERVER['HTTP_SEC_CH_UA_FULL_VERSION_LIST'], + $_SERVER['HTTP_SEC_FETCH_SITE'] + ); + } + } + + /** + * @see Proxy::send_event() + * @return void + */ + public function testSendEventForwardsHeaders() { + $proxy = new Proxy( false ); + $sent = []; + + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr-FR'; + $_SERVER['HTTP_SEC_CH_UA_MOBILE'] = '?1'; + + $capture = function ( $preempt, $args ) use ( &$sent ) { + $sent = $args['headers']; + + return [ 'headers' => [], 'body' => '', 'response' => [ 'code' => 202, 'message' => 'Accepted' ], 'cookies' => [] ]; + }; + + add_filter( 'pre_http_request', $capture, 10, 2 ); + + try { + $proxy->send_event( new \WP_REST_Request( 'POST' ) ); + + $this->assertEquals( 'fr-FR', $sent['Accept-Language'] ); + $this->assertEquals( '?1', $sent['Sec-CH-UA-Mobile'] ); + $this->assertEquals( 'application/json', $sent['Content-Type'] ); + $this->assertArrayHasKey( 'X-Forwarded-For', $sent ); + } finally { + remove_filter( 'pre_http_request', $capture ); + unset( $_SERVER['HTTP_ACCEPT_LANGUAGE'], $_SERVER['HTTP_SEC_CH_UA_MOBILE'] ); + } + } }