diff --git a/mu-plugin/plausible-proxy-speed-module.php b/mu-plugin/plausible-proxy-speed-module.php index 3907e93f..f57ec8a0 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,27 @@ 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(); @@ -46,19 +63,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 +95,278 @@ 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_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(); + } + } + + /** + * 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 + */ + 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->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( $home_host ) === $this->normalize_domain( $host ); + } + + /** + * @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 ) { + return false; + } + + if ( ! $host && strpos( $url, '/' ) === 0 ) { + return true; + } + + if ( ! $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() { + return strlen( $this->get_request_body() ) > self::MAX_REQUEST_BYTES; + } + + /** + * @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; + } + + if ( ! isset( $data['d'] ) || ! is_string( $data['d'] ) || $this->normalize_domain( $data['d'] ) !== $this->normalize_domain( $this->get_expected_domain() ) ) { + return false; + } + + 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; + } + + /** + * 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 + */ + 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 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..b5e57caf 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,250 @@ 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 $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. + * + * @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. + * + * @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->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 ); + } + + /** + * 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; + } + + 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], '.' ); + } + /** * Make sure our response code is returned, instead of the default 200 on success. * @@ -246,7 +486,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;