diff --git a/mu-plugin/plausible-proxy-speed-module.php b/mu-plugin/plausible-proxy-speed-module.php index 32c6bd1..3d904bc 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 the current request a request to our proxy? * @@ -25,18 +27,44 @@ class PlausibleProxySpeed { */ private $request_uri; + /** + * Proxy resources loaded from the DB. + * + * @var array + */ + private $resources = []; + + /** + * Cached request body. + * + * @var string|null + */ + private $raw_body = null; + /** * 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(); $this->init(); } + /** + * 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 : []; + } + /** * Helper method to retrieve Request URI. * @@ -50,50 +78,297 @@ private function get_request_uri() { * Check if the current request is a proxy request. * * The namespace must appear as a path segment under the REST prefix - * (e.g. /wp-json/[/...]). Substring matches in query + * (e.g., /wp-json/[/...]). Substring matches in query * strings, fragments, or unrelated path segments are rejected. * * @return bool */ private function is_proxy_request() { - $namespace = get_option( 'plausible_analytics_proxy_resources' )['namespace'] ?? ''; + $namespace = $this->resources['namespace'] ?? ''; if ( ! $namespace ) { return false; } - $path = parse_url( $this->request_uri, PHP_URL_PATH ); + return str_starts_with( $this->get_request_path(), '/wp-json/' . $namespace ); + } + + /** + * @return string + */ + private function get_request_path() { + return wp_parse_url( $this->request_uri, PHP_URL_PATH ) ?: ''; + } + + /** + * Add filters and actions. + * + * @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_rest_no_route(); + } + + if ( $this->get_request_method() !== 'POST' ) { + $this->send_rest_no_route(); + } + + if ( ! $this->has_json_content_type() ) { + $this->send_rest_no_route(); + } + + if ( ! $this->has_valid_provenance() ) { + $this->send_rest_no_route(); + } + + if ( $this->request_body_too_large() ) { + $this->send_rest_no_route(); + } + + if ( ! $this->has_valid_payload() ) { + $this->send_rest_no_route(); + } + } + + /** + * @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_exact_proxy_path() { + $namespace = $this->resources['namespace'] ?? ''; + $base = $this->resources['base'] ?? ''; + $endpoint = $this->resources['endpoint'] ?? ''; + + return '/wp-json/' . $namespace . '/v1/' . $base . '/' . $endpoint; + } + + /** + * 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.' ); + } + + /** + * @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; + } + + /** + * @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 str_starts_with( strtolower( $content_type ), 'application/json' ); + } + + /** + * @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->host_matches_home( $origin ) ) { + return true; + } + + 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 ( ! is_string( $path ) || $path === '' ) { + if ( ! $host ) { return false; } - /** - * @see rest_url() requires $wp_rewrite to be set. If it's not set yet, just assume this isn't a proxy request. - * - * @since v1.0.2 - */ - global $wp_rewrite; + 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() { + return strlen( $this->get_request_body() ) > self::MAX_REQUEST_BYTES; + } + + /** + * 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 ); + } - if ( $wp_rewrite === null ) { + return $this->raw_body; + } + + /** + * @return bool + */ + private function has_valid_payload() { + $data = json_decode( $this->get_request_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 ( ! isset( $data['n'] ) || ! is_string( $data['n'] ) || $data['n'] === '' || strlen( $data['n'] ) > 120 ) { return false; } - $expected = function_exists( 'rest_url' ) - ? untrailingslashit( (string) wp_parse_url( rest_url( trim( $namespace, '/' ) ), PHP_URL_PATH ) ) - : '/wp-json/' . trim( $namespace, '/' ); + if ( ! isset( $data['d'] ) || ! is_string( $data['d'] ) || $this->normalize_domain( $data['d'] ) !== $this->normalize_domain( $this->get_expected_domain() ) ) { + return false; + } - return $path === $expected - || str_starts_with( $path, $expected . '/' ); + if ( ! isset( $data['u'] ) || ! is_string( $data['u'] ) || $data['u'] === '' || strlen( $data['u'] ) > 2048 || ! $this->url_matches_home_host( $data['u'] ) ) { + return false; + } + + if ( isset( $data['p'] ) && ! is_array( $data['p'] ) ) { + return false; + } + + return true; } /** - * Add filters and actions. + * @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 preg_replace( '/^http(s?):\/\/(www\.)?/i', '', home_url() ); + } + + /** + * @param string $url * - * @return void + * @return bool */ - private function init() { - add_filter( 'option_active_plugins', [ $this, 'filter_active_plugins' ] ); + 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 ) { + return false; + } + + if ( ! $host && str_starts_with( $url, '/' ) ) { + return true; + } + + if ( ! $host ) { + return false; + } + + return $this->normalize_domain( $home_host ) === $this->normalize_domain( $host ); } /** diff --git a/src/Proxy.php b/src/Proxy.php index 1012cbd..c3e5d81 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. * @@ -80,16 +82,18 @@ private function init( $init ) { $settings = []; - if ( array_key_exists( 'option_name', $_POST ) && $_POST[ 'option_name' ] == 'proxy_enabled' && array_key_exists( 'option_value', $_POST ) && $_POST[ 'option_value' ] == 'on' ) { - $settings[ 'proxy_enabled' ] = 'on'; // @codeCoverageIgnore + if ( array_key_exists( 'option_name', $_POST ) && $_POST['option_name'] == 'proxy_enabled' && array_key_exists( 'option_value', $_POST ) && $_POST['option_value'] == 'on' ) { + $settings['proxy_enabled'] = 'on'; // @codeCoverageIgnore } - // No need to continue if Proxy isn't enabled . + // No need to continue if Proxy is disabled. if ( Helpers::proxy_enabled( $settings ) ) { add_action( 'rest_api_init', [ $this, 'register_route' ] ); } 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 ); } /** @@ -113,15 +117,15 @@ public function do_request( $name = 'pageview', $domain = '', $url = '', $props ]; // URL is required, so if no $url was set and no referer was found, attempt to create it from the REQUEST_URI server variable. - if ( empty( $body[ 'u' ] ) ) { - $body[ 'u' ] = $this->generate_event_url(); // @codeCoverageIgnore + if ( empty( $body['u'] ) ) { + $body['u'] = $this->generate_event_url(); // @codeCoverageIgnore } // Revenue events use a different approach. - if ( isset( $props[ 'revenue' ] ) ) { - $body[ 'revenue' ] = reset( $props ); // @codeCoverageIgnore + if ( isset( $props['revenue'] ) ) { + $body['revenue'] = reset( $props ); // @codeCoverageIgnore } elseif ( ! empty( $props ) ) { - $body[ 'p' ] = $props; // @codeCoverageIgnore + $body['p'] = $props; // @codeCoverageIgnore } $request->set_body( wp_json_encode( $body ) ); @@ -136,11 +140,11 @@ public function do_request( $name = 'pageview', $domain = '', $url = '', $props */ public function generate_event_url() { $url = ''; - $parts = parse_url( $_SERVER[ 'REQUEST_URI' ] ); + $parts = parse_url( $_SERVER['REQUEST_URI'] ); $home_url_parts = parse_url( get_home_url() ); - if ( isset( $home_url_parts[ 'scheme' ] ) && isset( $home_url_parts[ 'host' ] ) && isset( $parts[ 'path' ] ) ) { - $url = $home_url_parts[ 'scheme' ] . '://' . $home_url_parts [ 'host' ] . $parts[ 'path' ]; + if ( isset( $home_url_parts['scheme'] ) && isset( $home_url_parts['host'] ) && isset( $parts['path'] ) ) { + $url = $home_url_parts['scheme'] . '://' . $home_url_parts ['host'] . $parts['path']; } return $url; @@ -156,7 +160,7 @@ public function send_event( $request ) { $ip = $this->get_user_ip_address(); $url = 'https://plausible.io/api/event'; - $ua = ! empty ( $_SERVER[ 'HTTP_USER_AGENT' ] ) ? wp_kses( $_SERVER[ 'HTTP_USER_AGENT' ], 'strip' ) : ''; + $ua = ! empty ( $_SERVER['HTTP_USER_AGENT'] ) ? wp_kses( $_SERVER['HTTP_USER_AGENT'], 'strip' ) : ''; return wp_remote_post( $url, @@ -186,7 +190,7 @@ private function get_user_ip_address() { if ( strpos( $ip, ',' ) !== false ) { $ip = explode( ',', $ip ); - return $ip[ 0 ]; + return $ip[0]; } return $ip; @@ -207,6 +211,74 @@ private function header_exists( $global ) { return ! empty( $_SERVER[ $global ] ); } + /** + * Make sure our response code is returned, instead of the default 200 on success. + * + * @param WP_HTTP_Response $response + * @param WP_REST_Server $server + * @param WP_REST_Request $request + * + * @return WP_HTTP_Response + * + * @codeCoverageIgnore + */ + public function force_http_response_code( $response, $server, $request ) { + if ( strpos( $request->get_route(), $this->namespace ) === false ) { + return $response; // @codeCoverageIgnore + } + + $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; + } + + /** + * 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; + } + + /** + * 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 ] + ); + } + /** * Register the API route. * @@ -222,8 +294,7 @@ 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, ] @@ -231,24 +302,199 @@ public function register_route() { } /** - * Make sure our response code is returned, instead of the default 200 on success. + * Validate the proxy request before we forward it to Plausible. * - * @param WP_HTTP_Response $response - * @param WP_REST_Server $server - * @param WP_REST_Request $request + * @param WP_REST_Request $request * - * @return WP_HTTP_Response + * @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 $this->rest_no_route(); + } + + if ( ! $this->has_json_content_type() ) { + return $this->rest_no_route(); + } + + $params = $request->get_json_params(); + + if ( ! is_array( $params ) ) { + return $this->rest_no_route(); + } + + if ( ! $this->has_valid_provenance() ) { + return $this->rest_no_route(); + } + + if ( ! $this->has_valid_payload( $params ) ) { + return $this->rest_no_route(); + } + + return true; + } + + /** + * Uniform rejection so probes can't tell which check failed. * - * @codeCoverageIgnore + * @return WP_Error */ - public function force_http_response_code( $response, $server, $request ) { - if ( strpos( $request->get_route(), $this->namespace ) === false ) { - return $response; // @codeCoverageIgnore + 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. + * + * @return bool + */ + private function has_json_content_type() { + $content_type = $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? ''; + + if ( ! $content_type ) { + return false; } - $response_code = wp_remote_retrieve_response_code( $response->get_data() ); - $response->set_status( $response_code ); + return str_starts_with( strtolower( $content_type ), 'application/json' ); + } - return $response; + /** + * 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->host_matches_home( $origin ) ) { + return true; + } + + 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 ); + } + + /** + * 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], '.' ); + } + + /** + * 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 && str_starts_with( $url, '/' ) ) { + return true; + } + + if ( ! $host ) { + return false; + } + + return $this->normalize_domain( $host ) === $this->normalize_domain( $home_host ); } }