From 195fad2c91f3bd607d28ac2f9057cf5663678a83 Mon Sep 17 00:00:00 2001 From: Chandler Weiner <23106097+crweiner@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:08:51 -0400 Subject: [PATCH 1/6] Harden Plausible proxy endpoint handling --- mu-plugin/plausible-proxy-speed-module.php | 242 ++++++++++++++++++++- src/Proxy.php | 230 +++++++++++++++++++- 2 files changed, 467 insertions(+), 5 deletions(-) diff --git a/mu-plugin/plausible-proxy-speed-module.php b/mu-plugin/plausible-proxy-speed-module.php index 3907e93f..88e161d4 100644 --- a/mu-plugin/plausible-proxy-speed-module.php +++ b/mu-plugin/plausible-proxy-speed-module.php @@ -11,6 +11,8 @@ */ class PlausibleProxySpeed { + const MAX_REQUEST_BYTES = 8192; + /** * Is current request a request to our proxy? * @@ -25,12 +27,20 @@ class PlausibleProxySpeed { */ private $request_uri = ''; + /** + * Proxy resources loaded from the DB. + * + * @var array + */ + private $resources = []; + /** * Build properties. * * @return void */ public function __construct() { + $this->resources = $this->get_proxy_resources(); $this->request_uri = $this->get_request_uri(); $this->is_proxy_request = $this->is_proxy_request(); @@ -46,19 +56,30 @@ private function get_request_uri() { return $_SERVER[ 'REQUEST_URI' ]; } + /** + * Read proxy resources from the DB once. + * + * @return array + */ + private function get_proxy_resources() { + $resources = get_option( 'plausible_analytics_proxy_resources', [] ); + + return is_array( $resources ) ? $resources : []; + } + /** * Check if current request is a proxy request. * * @return bool */ private function is_proxy_request() { - $namespace = get_option( 'plausible_analytics_proxy_resources' )[ 'namespace' ] ?? ''; + $namespace = $this->resources[ 'namespace' ] ?? ''; if ( ! $namespace ) { return false; } - return strpos( $this->request_uri, $namespace ) !== false; + return strpos( $this->get_request_path(), '/wp-json/' . $namespace ) === 0; } /** @@ -67,9 +88,226 @@ private function is_proxy_request() { * @return void */ private function init() { + $this->maybe_short_circuit_request(); add_filter( 'option_active_plugins', [ $this, 'filter_active_plugins' ] ); } + /** + * Reject obvious probes as early as possible. + * + * @return void + */ + private function maybe_short_circuit_request() { + if ( ! $this->is_proxy_request ) { + return; + } + + if ( $this->is_namespace_index_request() || ! $this->is_exact_proxy_endpoint_request() ) { + $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); + } + + if ( $this->get_request_method() !== 'POST' ) { + $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); + } + + if ( ! $this->has_json_content_type() ) { + $this->send_json_error( 400, 'plausible_proxy_invalid_content_type', 'Proxy request must be sent as JSON.' ); + } + + if ( ! $this->has_valid_provenance() ) { + $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); + } + + if ( $this->request_body_too_large() ) { + $this->send_json_error( 413, 'plausible_proxy_body_too_large', 'Proxy request body too large.' ); + } + + if ( ! $this->has_valid_payload() ) { + $this->send_json_error( 400, 'plausible_proxy_invalid_payload', 'Proxy request payload is invalid.' ); + } + } + + /** + * @return string + */ + private function get_request_path() { + return wp_parse_url( $this->request_uri, PHP_URL_PATH ) ?: ''; + } + + /** + * @return string + */ + private function get_exact_proxy_path() { + $namespace = $this->resources[ 'namespace' ] ?? ''; + $base = $this->resources[ 'base' ] ?? ''; + $endpoint = $this->resources[ 'endpoint' ] ?? ''; + + return '/wp-json/' . $namespace . '/v1/' . $base . '/' . $endpoint; + } + + /** + * @return bool + */ + private function is_namespace_index_request() { + return $this->get_request_path() === '/wp-json/' . ( $this->resources[ 'namespace' ] ?? '' ) . '/v1'; + } + + /** + * @return bool + */ + private function is_exact_proxy_endpoint_request() { + return $this->get_request_path() === $this->get_exact_proxy_path(); + } + + /** + * @return string + */ + private function get_request_method() { + return strtoupper( $_SERVER[ 'REQUEST_METHOD' ] ?? 'GET' ); + } + + /** + * @return bool + */ + private function has_json_content_type() { + $content_type = $_SERVER[ 'CONTENT_TYPE' ] ?? $_SERVER[ 'HTTP_CONTENT_TYPE' ] ?? ''; + + return strpos( strtolower( $content_type ), 'application/json' ) === 0; + } + + /** + * @return bool + */ + private function has_valid_provenance() { + if ( ! apply_filters( 'plausible_analytics_proxy_require_same_origin', true ) ) { + return true; + } + + $origin = $_SERVER[ 'HTTP_ORIGIN' ] ?? ''; + $referer = $_SERVER[ 'HTTP_REFERER' ] ?? ''; + + if ( $origin && $this->url_matches_home_host( $origin ) ) { + return true; + } + + if ( $referer && $this->url_matches_home_host( $referer ) ) { + return true; + } + + return false; + } + + /** + * @param string $url + * + * @return bool + */ + private function url_matches_home_host( $url ) { + $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); + $host = wp_parse_url( $url, PHP_URL_HOST ); + + if ( ! $home_host || ! $host ) { + return false; + } + + return $this->normalize_domain( $home_host ) === $this->normalize_domain( $host ); + } + + /** + * @param string $domain + * + * @return string + */ + private function normalize_domain( $domain ) { + $domain = trim( strtolower( $domain ) ); + $domain = preg_replace( '/^https?:\/\//', '', $domain ); + $domain = preg_replace( '/^www\./', '', $domain ); + + $parts = explode( '/', $domain ); + + return rtrim( $parts[0], '.' ); + } + + /** + * @return bool + */ + private function request_body_too_large() { + $length = (int) ( $_SERVER[ 'CONTENT_LENGTH' ] ?? 0 ); + + return $length > self::MAX_REQUEST_BYTES; + } + + /** + * @return bool + */ + private function has_valid_payload() { + $body = file_get_contents( 'php://input' ); + $data = json_decode( $body, true ); + + if ( ! is_array( $data ) ) { + return false; + } + + $allowed_keys = [ 'n', 'd', 'u', 'p', 'revenue' ]; + + foreach ( array_keys( $data ) as $key ) { + if ( ! in_array( $key, $allowed_keys, true ) ) { + return false; + } + } + + if ( empty( $data['n'] ) || ! is_string( $data['n'] ) || strlen( $data['n'] ) > 120 ) { + return false; + } + + if ( empty( $data['d'] ) || $this->normalize_domain( $data['d'] ) !== $this->normalize_domain( $this->get_expected_domain() ) ) { + return false; + } + + if ( empty( $data['u'] ) || ! is_string( $data['u'] ) || ! $this->url_matches_home_host( $data['u'] ) ) { + return false; + } + + if ( isset( $data['p'] ) && ! is_array( $data['p'] ) ) { + return false; + } + + return true; + } + + /** + * @return string + */ + private function get_expected_domain() { + $settings = get_option( 'plausible_analytics_settings', [] ); + + if ( is_array( $settings ) && ! empty( $settings['domain_name'] ) ) { + return $settings['domain_name']; + } + + return home_url(); + } + + /** + * @param int $status + * @param string $code + * @param string $message + * + * @return void + */ + private function send_json_error( $status, $code, $message ) { + status_header( $status ); + header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) ); + echo wp_json_encode( + [ + 'code' => $code, + 'message' => $message, + 'data' => [ 'status' => $status ], + ] + ); + exit; + } + /** * Filter the list of active plugins for custom endpoint requests. * diff --git a/src/Proxy.php b/src/Proxy.php index 1012cbde..e664bddb 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -19,6 +19,8 @@ use WP_REST_Server; class Proxy { + const MAX_REQUEST_BYTES = 8192; + /** * Proxy IP Headers used to detect the visitors IP prior to sending the data to Plausible's Measurement Protocol. * @@ -90,6 +92,8 @@ private function init( $init ) { } add_filter( 'rest_post_dispatch', [ $this, 'force_http_response_code' ], null, 3 ); + add_filter( 'rest_pre_dispatch', [ $this, 'maybe_block_namespace_index' ], 10, 3 ); + add_filter( 'rest_route_data', [ $this, 'hide_route_discovery' ], 10, 2 ); } /** @@ -222,14 +226,228 @@ public function register_route() { [ 'methods' => 'POST', 'callback' => [ $this, 'send_event' ], - // There's no reason not to allow access to this API. - 'permission_callback' => '__return_true', + 'permission_callback' => [ $this, 'validate_proxy_request' ], ], 'schema' => null, ] ); } + /** + * Reject namespace index probing so the randomized route is not self-discoverable. + * + * @param mixed $result + * @param WP_REST_Server $server + * @param WP_REST_Request $request + * + * @return mixed + */ + public function maybe_block_namespace_index( $result, $server, $request ) { + if ( ! Helpers::proxy_enabled() || $request->get_route() !== '/' . $this->namespace ) { + return $result; + } + + return new WP_Error( + 'rest_no_route', + __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), + [ 'status' => 404 ] + ); + } + + /** + * Remove the proxy routes from REST discovery output. + * + * @param array $available + * @param array $routes + * + * @return array + */ + public function hide_route_discovery( $available, $routes ) { + if ( ! Helpers::proxy_enabled() ) { + return $available; + } + + unset( $available[ '/' . $this->namespace ] ); + unset( $available[ '/' . $this->namespace . '/' . $this->base . '/' . $this->endpoint ] ); + + return $available; + } + + /** + * Validate the proxy request before we forward it to Plausible. + * + * @param WP_REST_Request $request + * + * @return true|WP_Error + */ + public function validate_proxy_request( $request ) { + $max_request_bytes = (int) apply_filters( 'plausible_analytics_proxy_max_body_bytes', self::MAX_REQUEST_BYTES ); + $raw_body = (string) $request->get_body(); + + if ( $max_request_bytes > 0 && strlen( $raw_body ) > $max_request_bytes ) { + return new WP_Error( + 'plausible_proxy_body_too_large', + __( 'Proxy request body too large.', 'plausible-analytics' ), + [ 'status' => 413 ] + ); + } + + if ( ! $this->has_json_content_type() ) { + return new WP_Error( + 'plausible_proxy_invalid_content_type', + __( 'Proxy request must be sent as JSON.', 'plausible-analytics' ), + [ 'status' => 400 ] + ); + } + + $params = $request->get_json_params(); + + if ( ! is_array( $params ) ) { + return new WP_Error( + 'plausible_proxy_invalid_json', + __( 'Proxy request payload must be valid JSON.', 'plausible-analytics' ), + [ 'status' => 400 ] + ); + } + + if ( ! $this->has_valid_provenance() ) { + return new WP_Error( + 'rest_no_route', + __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), + [ 'status' => 404 ] + ); + } + + if ( ! $this->has_valid_payload( $params ) ) { + return new WP_Error( + 'plausible_proxy_invalid_payload', + __( 'Proxy request payload is invalid.', 'plausible-analytics' ), + [ 'status' => 400 ] + ); + } + + return true; + } + + /** + * Check the request's Content-Type header. + * + * @return bool + */ + private function has_json_content_type() { + $content_type = $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? ''; + + if ( ! $content_type ) { + return false; + } + + return strpos( strtolower( $content_type ), 'application/json' ) === 0; + } + + /** + * Require same-site Origin or Referer headers so blind scanners are rejected. + * + * @return bool + */ + private function has_valid_provenance() { + $require_provenance = apply_filters( 'plausible_analytics_proxy_require_same_origin', true ); + + if ( ! $require_provenance ) { + return true; + } + + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + + if ( $origin && $this->url_matches_home_host( $origin ) ) { + return true; + } + + if ( $referer && $this->url_matches_home_host( $referer ) ) { + return true; + } + + return false; + } + + /** + * Validate the JSON payload sent by the tracker. + * + * @param array $params + * + * @return bool + */ + private function has_valid_payload( $params ) { + $allowed_keys = [ 'n', 'd', 'u', 'p', 'revenue' ]; + $event_name = $params['n'] ?? ''; + $domain = $params['d'] ?? ''; + $url = $params['u'] ?? ''; + + foreach ( array_keys( $params ) as $key ) { + if ( ! in_array( $key, $allowed_keys, true ) ) { + return false; + } + } + + if ( ! is_string( $event_name ) || $event_name === '' || strlen( $event_name ) > 120 ) { + return false; + } + + if ( ! is_string( $domain ) || $this->normalize_domain( $domain ) !== $this->normalize_domain( Helpers::get_domain() ) ) { + return false; + } + + if ( ! is_string( $url ) || strlen( $url ) > 2048 || ! $this->url_matches_home_host( $url ) ) { + return false; + } + + if ( isset( $params['p'] ) && ! is_array( $params['p'] ) ) { + return false; + } + + return true; + } + + /** + * Compare a URL-like value to the current site's host. + * + * @param string $url + * + * @return bool + */ + private function url_matches_home_host( $url ) { + $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); + + if ( ! $home_host ) { + return false; + } + + $host = wp_parse_url( $url, PHP_URL_HOST ); + + if ( ! $host && strpos( $url, '/' ) === 0 ) { + return true; + } + + return $this->normalize_domain( $host ) === $this->normalize_domain( $home_host ); + } + + /** + * Normalize a host/domain string for comparison. + * + * @param string $domain + * + * @return string + */ + private function normalize_domain( $domain ) { + $domain = trim( strtolower( $domain ) ); + $domain = preg_replace( '/^https?:\/\//', '', $domain ); + $domain = preg_replace( '/^www\./', '', $domain ); + + $parts = explode( '/', $domain ); + + return rtrim( $parts[0], '.' ); + } + /** * Make sure our response code is returned, instead of the default 200 on success. * @@ -246,7 +464,13 @@ public function force_http_response_code( $response, $server, $request ) { return $response; // @codeCoverageIgnore } - $response_code = wp_remote_retrieve_response_code( $response->get_data() ); + $data = $response->get_data(); + + if ( ! is_array( $data ) || empty( $data['response']['code'] ) ) { + return $response; + } + + $response_code = wp_remote_retrieve_response_code( $data ); $response->set_status( $response_code ); return $response; From cf3f01a85f3c929c704471f2570f4b11a4af5422 Mon Sep 17 00:00:00 2001 From: Chandler Weiner <23106097+crweiner@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:58:28 -0400 Subject: [PATCH 2/6] Tighten MU proxy request validation --- mu-plugin/plausible-proxy-speed-module.php | 37 ++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/mu-plugin/plausible-proxy-speed-module.php b/mu-plugin/plausible-proxy-speed-module.php index 88e161d4..088ea306 100644 --- a/mu-plugin/plausible-proxy-speed-module.php +++ b/mu-plugin/plausible-proxy-speed-module.php @@ -34,6 +34,13 @@ class PlausibleProxySpeed { */ private $resources = []; + /** + * Cached request body. + * + * @var string|null + */ + private $raw_body = null; + /** * Build properties. * @@ -206,7 +213,15 @@ private function url_matches_home_host( $url ) { $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); $host = wp_parse_url( $url, PHP_URL_HOST ); - if ( ! $home_host || ! $host ) { + if ( ! $home_host ) { + return false; + } + + if ( ! $host && strpos( $url, '/' ) === 0 ) { + return true; + } + + if ( ! $host ) { return false; } @@ -232,17 +247,14 @@ private function normalize_domain( $domain ) { * @return bool */ private function request_body_too_large() { - $length = (int) ( $_SERVER[ 'CONTENT_LENGTH' ] ?? 0 ); - - return $length > self::MAX_REQUEST_BYTES; + return strlen( $this->get_request_body() ) > self::MAX_REQUEST_BYTES; } /** * @return bool */ private function has_valid_payload() { - $body = file_get_contents( 'php://input' ); - $data = json_decode( $body, true ); + $data = json_decode( $this->get_request_body(), true ); if ( ! is_array( $data ) ) { return false; @@ -275,6 +287,19 @@ private function has_valid_payload() { return true; } + /** + * Read and cache the request body once, capped slightly above the accepted limit. + * + * @return string + */ + private function get_request_body() { + if ( $this->raw_body === null ) { + $this->raw_body = (string) file_get_contents( 'php://input', false, null, 0, self::MAX_REQUEST_BYTES + 1 ); + } + + return $this->raw_body; + } + /** * @return string */ From 06555ad5f08ec7e96a8a3eeabb2f05e5eea944c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 03:39:03 +0000 Subject: [PATCH 3/6] Fix inconsistencies in proxy validation: add explicit null host check in Proxy.php and URL length check in MU plugin Agent-Logs-Url: https://github.com/crweiner/plausible-wordpress-plugin/sessions/b35d7e5f-5764-4ad1-9b52-a62192c6ac6b Co-authored-by: crweiner <23106097+crweiner@users.noreply.github.com> --- mu-plugin/plausible-proxy-speed-module.php | 2 +- src/Proxy.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mu-plugin/plausible-proxy-speed-module.php b/mu-plugin/plausible-proxy-speed-module.php index 088ea306..e8da645d 100644 --- a/mu-plugin/plausible-proxy-speed-module.php +++ b/mu-plugin/plausible-proxy-speed-module.php @@ -276,7 +276,7 @@ private function has_valid_payload() { return false; } - if ( empty( $data['u'] ) || ! is_string( $data['u'] ) || ! $this->url_matches_home_host( $data['u'] ) ) { + if ( empty( $data['u'] ) || ! is_string( $data['u'] ) || strlen( $data['u'] ) > 2048 || ! $this->url_matches_home_host( $data['u'] ) ) { return false; } diff --git a/src/Proxy.php b/src/Proxy.php index e664bddb..3c3f05d7 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -428,6 +428,10 @@ private function url_matches_home_host( $url ) { return true; } + if ( ! $host ) { + return false; + } + return $this->normalize_domain( $host ) === $this->normalize_domain( $home_host ); } From e1aa27c2258a8af3d9e627842d0703230db26759 Mon Sep 17 00:00:00 2001 From: Glynn Quelch Date: Wed, 15 Apr 2026 14:03:42 +0100 Subject: [PATCH 4/6] Added hardening to the host matching and also to ensure that all routes return a 404 so you cant assume an endpoint exists based on response --- mu-plugin/plausible-proxy-speed-module.php | 50 ++++++++++++--- src/Proxy.php | 72 ++++++++++++++-------- 2 files changed, 87 insertions(+), 35 deletions(-) diff --git a/mu-plugin/plausible-proxy-speed-module.php b/mu-plugin/plausible-proxy-speed-module.php index e8da645d..8fa77a4d 100644 --- a/mu-plugin/plausible-proxy-speed-module.php +++ b/mu-plugin/plausible-proxy-speed-module.php @@ -110,30 +110,39 @@ private function maybe_short_circuit_request() { } if ( $this->is_namespace_index_request() || ! $this->is_exact_proxy_endpoint_request() ) { - $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); + $this->send_rest_no_route(); } if ( $this->get_request_method() !== 'POST' ) { - $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); + $this->send_rest_no_route(); } if ( ! $this->has_json_content_type() ) { - $this->send_json_error( 400, 'plausible_proxy_invalid_content_type', 'Proxy request must be sent as JSON.' ); + $this->send_rest_no_route(); } if ( ! $this->has_valid_provenance() ) { - $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); + $this->send_rest_no_route(); } if ( $this->request_body_too_large() ) { - $this->send_json_error( 413, 'plausible_proxy_body_too_large', 'Proxy request body too large.' ); + $this->send_rest_no_route(); } if ( ! $this->has_valid_payload() ) { - $this->send_json_error( 400, 'plausible_proxy_invalid_payload', 'Proxy request payload is invalid.' ); + $this->send_rest_no_route(); } } + /** + * Uniform rejection so probes can't tell which check failed. + * + * @return void + */ + private function send_rest_no_route() { + $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); + } + /** * @return string */ @@ -193,17 +202,42 @@ private function has_valid_provenance() { $origin = $_SERVER[ 'HTTP_ORIGIN' ] ?? ''; $referer = $_SERVER[ 'HTTP_REFERER' ] ?? ''; - if ( $origin && $this->url_matches_home_host( $origin ) ) { + if ( $origin && $this->host_matches_home( $origin ) ) { return true; } - if ( $referer && $this->url_matches_home_host( $referer ) ) { + if ( $referer && $this->host_matches_home( $referer ) ) { return true; } return false; } + /** + * Strict same-host check for HTTP headers (Origin/Referer). + * + * Rejects relative paths — headers must carry a full origin. + * + * @param string $url + * + * @return bool + */ + private function host_matches_home( $url ) { + $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); + + if ( ! $home_host ) { + return false; + } + + $host = wp_parse_url( $url, PHP_URL_HOST ); + + if ( ! $host ) { + return false; + } + + return $this->normalize_domain( $home_host ) === $this->normalize_domain( $host ); + } + /** * @param string $url * diff --git a/src/Proxy.php b/src/Proxy.php index 3c3f05d7..b5e57caf 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -285,50 +285,43 @@ public function validate_proxy_request( $request ) { $raw_body = (string) $request->get_body(); if ( $max_request_bytes > 0 && strlen( $raw_body ) > $max_request_bytes ) { - return new WP_Error( - 'plausible_proxy_body_too_large', - __( 'Proxy request body too large.', 'plausible-analytics' ), - [ 'status' => 413 ] - ); + return $this->rest_no_route(); } if ( ! $this->has_json_content_type() ) { - return new WP_Error( - 'plausible_proxy_invalid_content_type', - __( 'Proxy request must be sent as JSON.', 'plausible-analytics' ), - [ 'status' => 400 ] - ); + return $this->rest_no_route(); } $params = $request->get_json_params(); if ( ! is_array( $params ) ) { - return new WP_Error( - 'plausible_proxy_invalid_json', - __( 'Proxy request payload must be valid JSON.', 'plausible-analytics' ), - [ 'status' => 400 ] - ); + return $this->rest_no_route(); } if ( ! $this->has_valid_provenance() ) { - return new WP_Error( - 'rest_no_route', - __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), - [ 'status' => 404 ] - ); + return $this->rest_no_route(); } if ( ! $this->has_valid_payload( $params ) ) { - return new WP_Error( - 'plausible_proxy_invalid_payload', - __( 'Proxy request payload is invalid.', 'plausible-analytics' ), - [ 'status' => 400 ] - ); + return $this->rest_no_route(); } return true; } + /** + * Uniform rejection so probes can't tell which check failed. + * + * @return WP_Error + */ + private function rest_no_route() { + return new WP_Error( + 'rest_no_route', + __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), + [ 'status' => 404 ] + ); + } + /** * Check the request's Content-Type header. * @@ -359,17 +352,42 @@ private function has_valid_provenance() { $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $referer = $_SERVER['HTTP_REFERER'] ?? ''; - if ( $origin && $this->url_matches_home_host( $origin ) ) { + if ( $origin && $this->host_matches_home( $origin ) ) { return true; } - if ( $referer && $this->url_matches_home_host( $referer ) ) { + if ( $referer && $this->host_matches_home( $referer ) ) { return true; } return false; } + /** + * Strict same-host check for HTTP headers (Origin/Referer). + * + * Rejects relative paths — headers must carry a full origin. + * + * @param string $url + * + * @return bool + */ + private function host_matches_home( $url ) { + $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); + + if ( ! $home_host ) { + return false; + } + + $host = wp_parse_url( $url, PHP_URL_HOST ); + + if ( ! $host ) { + return false; + } + + return $this->normalize_domain( $host ) === $this->normalize_domain( $home_host ); + } + /** * Validate the JSON payload sent by the tracker. * From 10de90c996cab4e5d586697ee7d51ecf08577f72 Mon Sep 17 00:00:00 2001 From: Glynn Quelch Date: Tue, 28 Apr 2026 13:47:56 +0100 Subject: [PATCH 5/6] Mirror Proxy.php payload validation in MU speed module - Guard n/d/u with isset + is_string before string ops to avoid TypeError on non-string payloads (which would surface as 500s and defeat the uniform-404 design). - Use === '' instead of empty() for n and u so legitimate values like 0 aren't rejected. - Strip scheme and www. from the home_url() fallback in get_expected_domain() so it returns a bare domain matching Helpers::get_domain(), preventing silent rejection of valid payloads on sites without a configured domain_name setting. --- CHANGES.md | 154 +++++++++++++++++++++ mu-plugin/plausible-proxy-speed-module.php | 8 +- 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..d5dd0c77 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,154 @@ +# Follow-up patches + +Two fixes on top of `feature/harden-proxy-api`: + +1. All rejections return `404 rest_no_route` (stops endpoint discovery via error codes). +2. `Origin`/`Referer` checks now require a real host (stops `Origin: /` bypass). + +--- + +## `src/Proxy.php` + +```diff + public function validate_proxy_request( $request ) { + $max_request_bytes = (int) apply_filters( 'plausible_analytics_proxy_max_body_bytes', self::MAX_REQUEST_BYTES ); + $raw_body = (string) $request->get_body(); + + if ( $max_request_bytes > 0 && strlen( $raw_body ) > $max_request_bytes ) { +- return new WP_Error( 'plausible_proxy_body_too_large', __( 'Proxy request body too large.', 'plausible-analytics' ), [ 'status' => 413 ] ); ++ return $this->rest_no_route(); + } + + if ( ! $this->has_json_content_type() ) { +- return new WP_Error( 'plausible_proxy_invalid_content_type', __( 'Proxy request must be sent as JSON.', 'plausible-analytics' ), [ 'status' => 400 ] ); ++ return $this->rest_no_route(); + } + + $params = $request->get_json_params(); + + if ( ! is_array( $params ) ) { +- return new WP_Error( 'plausible_proxy_invalid_json', __( 'Proxy request payload must be valid JSON.', 'plausible-analytics' ), [ 'status' => 400 ] ); ++ return $this->rest_no_route(); + } + + if ( ! $this->has_valid_provenance() ) { +- return new WP_Error( 'rest_no_route', __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), [ 'status' => 404 ] ); ++ return $this->rest_no_route(); + } + + if ( ! $this->has_valid_payload( $params ) ) { +- return new WP_Error( 'plausible_proxy_invalid_payload', __( 'Proxy request payload is invalid.', 'plausible-analytics' ), [ 'status' => 400 ] ); ++ return $this->rest_no_route(); + } + + return true; + } + ++private function rest_no_route() { ++ return new WP_Error( ++ 'rest_no_route', ++ __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), ++ [ 'status' => 404 ] ++ ); ++} + + private function has_valid_provenance() { + ... +- if ( $origin && $this->url_matches_home_host( $origin ) ) { ++ if ( $origin && $this->host_matches_home( $origin ) ) { + return true; + } + +- if ( $referer && $this->url_matches_home_host( $referer ) ) { ++ if ( $referer && $this->host_matches_home( $referer ) ) { + return true; + } + + return false; + } + ++private function host_matches_home( $url ) { ++ $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); ++ if ( ! $home_host ) { ++ return false; ++ } ++ $host = wp_parse_url( $url, PHP_URL_HOST ); ++ if ( ! $host ) { ++ return false; ++ } ++ return $this->normalize_domain( $host ) === $this->normalize_domain( $home_host ); ++} +``` + +--- + +## `mu-plugin/plausible-proxy-speed-module.php` + +```diff + private function maybe_short_circuit_request() { + if ( ! $this->is_proxy_request ) { + return; + } + + if ( $this->is_namespace_index_request() || ! $this->is_exact_proxy_endpoint_request() ) { +- $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); ++ $this->send_rest_no_route(); + } + + if ( $this->get_request_method() !== 'POST' ) { +- $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); ++ $this->send_rest_no_route(); + } + + if ( ! $this->has_json_content_type() ) { +- $this->send_json_error( 400, 'plausible_proxy_invalid_content_type', 'Proxy request must be sent as JSON.' ); ++ $this->send_rest_no_route(); + } + + if ( ! $this->has_valid_provenance() ) { +- $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); ++ $this->send_rest_no_route(); + } + + if ( $this->request_body_too_large() ) { +- $this->send_json_error( 413, 'plausible_proxy_body_too_large', 'Proxy request body too large.' ); ++ $this->send_rest_no_route(); + } + + if ( ! $this->has_valid_payload() ) { +- $this->send_json_error( 400, 'plausible_proxy_invalid_payload', 'Proxy request payload is invalid.' ); ++ $this->send_rest_no_route(); + } + } + ++private function send_rest_no_route() { ++ $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); ++} + + private function has_valid_provenance() { + ... +- if ( $origin && $this->url_matches_home_host( $origin ) ) { ++ if ( $origin && $this->host_matches_home( $origin ) ) { + return true; + } + +- if ( $referer && $this->url_matches_home_host( $referer ) ) { ++ if ( $referer && $this->host_matches_home( $referer ) ) { + return true; + } + + return false; + } + ++private function host_matches_home( $url ) { ++ $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); ++ if ( ! $home_host ) { ++ return false; ++ } ++ $host = wp_parse_url( $url, PHP_URL_HOST ); ++ if ( ! $host ) { ++ return false; ++ } ++ return $this->normalize_domain( $home_host ) === $this->normalize_domain( $host ); ++} +``` diff --git a/mu-plugin/plausible-proxy-speed-module.php b/mu-plugin/plausible-proxy-speed-module.php index 8fa77a4d..f57ec8a0 100644 --- a/mu-plugin/plausible-proxy-speed-module.php +++ b/mu-plugin/plausible-proxy-speed-module.php @@ -302,15 +302,15 @@ private function has_valid_payload() { } } - if ( empty( $data['n'] ) || ! is_string( $data['n'] ) || strlen( $data['n'] ) > 120 ) { + if ( ! isset( $data['n'] ) || ! is_string( $data['n'] ) || $data['n'] === '' || strlen( $data['n'] ) > 120 ) { return false; } - if ( empty( $data['d'] ) || $this->normalize_domain( $data['d'] ) !== $this->normalize_domain( $this->get_expected_domain() ) ) { + if ( ! isset( $data['d'] ) || ! is_string( $data['d'] ) || $this->normalize_domain( $data['d'] ) !== $this->normalize_domain( $this->get_expected_domain() ) ) { return false; } - if ( empty( $data['u'] ) || ! is_string( $data['u'] ) || strlen( $data['u'] ) > 2048 || ! $this->url_matches_home_host( $data['u'] ) ) { + if ( ! isset( $data['u'] ) || ! is_string( $data['u'] ) || $data['u'] === '' || strlen( $data['u'] ) > 2048 || ! $this->url_matches_home_host( $data['u'] ) ) { return false; } @@ -344,7 +344,7 @@ private function get_expected_domain() { return $settings['domain_name']; } - return home_url(); + return preg_replace( '/^http(s?):\/\/(www\.)?/i', '', home_url() ); } /** From e79f25287491f3f320107db1df49137ec396232a Mon Sep 17 00:00:00 2001 From: Chandler Weiner Date: Wed, 13 May 2026 14:55:05 -0400 Subject: [PATCH 6/6] Delete CHANGES.md --- CHANGES.md | 154 ----------------------------------------------------- 1 file changed, 154 deletions(-) delete mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md deleted file mode 100644 index d5dd0c77..00000000 --- a/CHANGES.md +++ /dev/null @@ -1,154 +0,0 @@ -# Follow-up patches - -Two fixes on top of `feature/harden-proxy-api`: - -1. All rejections return `404 rest_no_route` (stops endpoint discovery via error codes). -2. `Origin`/`Referer` checks now require a real host (stops `Origin: /` bypass). - ---- - -## `src/Proxy.php` - -```diff - public function validate_proxy_request( $request ) { - $max_request_bytes = (int) apply_filters( 'plausible_analytics_proxy_max_body_bytes', self::MAX_REQUEST_BYTES ); - $raw_body = (string) $request->get_body(); - - if ( $max_request_bytes > 0 && strlen( $raw_body ) > $max_request_bytes ) { -- return new WP_Error( 'plausible_proxy_body_too_large', __( 'Proxy request body too large.', 'plausible-analytics' ), [ 'status' => 413 ] ); -+ return $this->rest_no_route(); - } - - if ( ! $this->has_json_content_type() ) { -- return new WP_Error( 'plausible_proxy_invalid_content_type', __( 'Proxy request must be sent as JSON.', 'plausible-analytics' ), [ 'status' => 400 ] ); -+ return $this->rest_no_route(); - } - - $params = $request->get_json_params(); - - if ( ! is_array( $params ) ) { -- return new WP_Error( 'plausible_proxy_invalid_json', __( 'Proxy request payload must be valid JSON.', 'plausible-analytics' ), [ 'status' => 400 ] ); -+ return $this->rest_no_route(); - } - - if ( ! $this->has_valid_provenance() ) { -- return new WP_Error( 'rest_no_route', __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), [ 'status' => 404 ] ); -+ return $this->rest_no_route(); - } - - if ( ! $this->has_valid_payload( $params ) ) { -- return new WP_Error( 'plausible_proxy_invalid_payload', __( 'Proxy request payload is invalid.', 'plausible-analytics' ), [ 'status' => 400 ] ); -+ return $this->rest_no_route(); - } - - return true; - } - -+private function rest_no_route() { -+ return new WP_Error( -+ 'rest_no_route', -+ __( 'No route was found matching the URL and request method.', 'plausible-analytics' ), -+ [ 'status' => 404 ] -+ ); -+} - - private function has_valid_provenance() { - ... -- if ( $origin && $this->url_matches_home_host( $origin ) ) { -+ if ( $origin && $this->host_matches_home( $origin ) ) { - return true; - } - -- if ( $referer && $this->url_matches_home_host( $referer ) ) { -+ if ( $referer && $this->host_matches_home( $referer ) ) { - return true; - } - - return false; - } - -+private function host_matches_home( $url ) { -+ $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); -+ if ( ! $home_host ) { -+ return false; -+ } -+ $host = wp_parse_url( $url, PHP_URL_HOST ); -+ if ( ! $host ) { -+ return false; -+ } -+ return $this->normalize_domain( $host ) === $this->normalize_domain( $home_host ); -+} -``` - ---- - -## `mu-plugin/plausible-proxy-speed-module.php` - -```diff - private function maybe_short_circuit_request() { - if ( ! $this->is_proxy_request ) { - return; - } - - if ( $this->is_namespace_index_request() || ! $this->is_exact_proxy_endpoint_request() ) { -- $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); -+ $this->send_rest_no_route(); - } - - if ( $this->get_request_method() !== 'POST' ) { -- $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); -+ $this->send_rest_no_route(); - } - - if ( ! $this->has_json_content_type() ) { -- $this->send_json_error( 400, 'plausible_proxy_invalid_content_type', 'Proxy request must be sent as JSON.' ); -+ $this->send_rest_no_route(); - } - - if ( ! $this->has_valid_provenance() ) { -- $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); -+ $this->send_rest_no_route(); - } - - if ( $this->request_body_too_large() ) { -- $this->send_json_error( 413, 'plausible_proxy_body_too_large', 'Proxy request body too large.' ); -+ $this->send_rest_no_route(); - } - - if ( ! $this->has_valid_payload() ) { -- $this->send_json_error( 400, 'plausible_proxy_invalid_payload', 'Proxy request payload is invalid.' ); -+ $this->send_rest_no_route(); - } - } - -+private function send_rest_no_route() { -+ $this->send_json_error( 404, 'rest_no_route', 'No route was found matching the URL and request method.' ); -+} - - private function has_valid_provenance() { - ... -- if ( $origin && $this->url_matches_home_host( $origin ) ) { -+ if ( $origin && $this->host_matches_home( $origin ) ) { - return true; - } - -- if ( $referer && $this->url_matches_home_host( $referer ) ) { -+ if ( $referer && $this->host_matches_home( $referer ) ) { - return true; - } - - return false; - } - -+private function host_matches_home( $url ) { -+ $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); -+ if ( ! $home_host ) { -+ return false; -+ } -+ $host = wp_parse_url( $url, PHP_URL_HOST ); -+ if ( ! $host ) { -+ return false; -+ } -+ return $this->normalize_domain( $home_host ) === $this->normalize_domain( $host ); -+} -```