-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/harden proxy api #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
195fad2
cf3f01a
06555ad
e1aa27c
9786201
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
Comment on lines
+197
to
+214
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -nP --type=php -C2 "plausible_analytics_proxy_require_same_origin"Repository: a8cteam51/plausible-wordpress-plugin Length of output: 737 🏁 Script executed: #!/bin/bash
# Find the constructor and where has_valid_provenance is called in the MU plugin
rg -n "function __construct|has_valid_provenance" mu-plugin/plausible-proxy-speed-module.php -A 5Repository: a8cteam51/plausible-wordpress-plugin Length of output: 719 🏁 Script executed: #!/bin/bash
# Check the full context of the MU plugin to understand initialization
head -50 mu-plugin/plausible-proxy-speed-module.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 970 🏁 Script executed: #!/bin/bash
# Search for where the MU plugin class is instantiated
rg -n "new.*plausible" mu-plugin/plausible-proxy-speed-module.php -iRepository: a8cteam51/plausible-wordpress-plugin Length of output: 110 🏁 Script executed: #!/bin/bash
# Check the init() method
sed -n '57,130p' mu-plugin/plausible-proxy-speed-module.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 1650 🏁 Script executed: #!/bin/bash
# Check context around instantiation at line 398
tail -20 mu-plugin/plausible-proxy-speed-module.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 493 🏁 Script executed: #!/bin/bash
# Check if there are any hooks where the class is registered
rg -n "add_action|do_action|add_filter" mu-plugin/plausible-proxy-speed-module.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 159 🏁 Script executed: #!/bin/bash
# Find send_rest_no_route method
rg -n "send_rest_no_route" mu-plugin/plausible-proxy-speed-module.php -A 5Repository: a8cteam51/plausible-wordpress-plugin Length of output: 901 🏁 Script executed: #!/bin/bash
# Check WordPress mu-plugin loading documentation info - when are filters available?
# Let's also verify if there's any filter registration happening before instantiation
rg -n "^[^/]*apply_filters|^[^/]*add_filter" mu-plugin/plausible-proxy-speed-module.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 247 🏁 Script executed: #!/bin/bash
# Let's verify the WordPress hook firing order - when init happens vs when plugins load
# Check if there are any comments about timing or hook order
rg -n "init|load|wp_" mu-plugin/plausible-proxy-speed-module.php | head -20Repository: a8cteam51/plausible-wordpress-plugin Length of output: 583 🏁 Script executed: #!/bin/bash
# Check if the regular plugin (src/Proxy.php) registers any callbacks before its filter use
rg -B20 "apply_filters.*plausible_analytics_proxy_require_same_origin" src/Proxy.php | head -40Repository: a8cteam51/plausible-wordpress-plugin Length of output: 655 🏁 Script executed: #!/bin/bash
# Let's see the full context around the Proxy.php usage of the same filter
sed -n '340,360p' src/Proxy.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 629 🏁 Script executed: #!/bin/bash
# Check when exactly has_valid_provenance is called - need to understand is_proxy_request initialization
sed -n '49,105p' mu-plugin/plausible-proxy-speed-module.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 1308 🏁 Script executed: #!/bin/bash
# Let's check if there's any way filters could be registered before the MU plugin runs
# Look for wp-config or other early loading mechanisms
git ls-files | grep -E "(wp-config|config|setup)" | head -10Repository: a8cteam51/plausible-wordpress-plugin Length of output: 148 🏁 Script executed: #!/bin/bash
# Verify when MU plugins are loaded relative to regular plugins and when filters can be registered
# Check if there's any documentation in the plugin about this
rg -n "mu.plugin|must.use|load|filter.*register" . -i --type=php --type=md | grep -i "mu\|must\|load\|early" | head -10Repository: a8cteam51/plausible-wordpress-plugin Length of output: 1118 🏁 Script executed: #!/bin/bash
# Let's check when the main Plausible Analytics plugin is loaded and when it might register filters
rg -n "register_activation_hook|plugins_loaded|init" src/Plugin.php 2>/dev/null | head -20Repository: a8cteam51/plausible-wordpress-plugin Length of output: 396 🏁 Script executed: #!/bin/bash
# Check if there's any documentation about the architecture
find . -name "README*" -o -name "*.md" | head -5 | xargs cat 2>/dev/null | head -50Repository: a8cteam51/plausible-wordpress-plugin Length of output: 2728 🏁 Script executed: #!/bin/bash
# Check if the filter is ever registered with add_filter anywhere in the codebase
rg -n "add_filter.*plausible_analytics_proxy_require_same_origin" .Repository: a8cteam51/plausible-wordpress-plugin Length of output: 62 🏁 Script executed: #!/bin/bash
# Also search for any function that registers this filter
rg -n "plausible_analytics_proxy_require_same_origin" . --type=php | grep -v "apply_filters"Repository: a8cteam51/plausible-wordpress-plugin Length of output: 62 🏁 Script executed: #!/bin/bash
# Let's trace where Proxy.php is instantiated and when it runs
rg -n "new Proxy|Proxy::" src/ --type=php | head -20Repository: a8cteam51/plausible-wordpress-plugin Length of output: 804 🏁 Script executed: #!/bin/bash
# Check if Proxy.php is loaded through the main plugin flow
rg -n "class Proxy\|function.*proxy" src/Plugin.php -A 3 | head -30Repository: a8cteam51/plausible-wordpress-plugin Length of output: 62 🏁 Script executed: #!/bin/bash
# Let's understand: when is Proxy.php actually used? On every request or only sometimes?
rg -B5 -A5 "has_valid_provenance" src/Proxy.php | head -50Repository: a8cteam51/plausible-wordpress-plugin Length of output: 624 Filter has no registered callbacks and is effectively unused.
🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 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 ( 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'] ) || strlen( $data['u'] ) > 2048 || ! $this->url_matches_home_host( $data['u'] ) ) { | ||
| return false; | ||
| } | ||
|
Comment on lines
+305
to
+315
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Also worth aligning 🐛 Proposed fix- 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;
}🤖 Prompt for AI Agents |
||
|
|
||
| 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 home_url(); | ||
| } | ||
|
Comment on lines
+340
to
+348
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect Helpers::get_domain implementation so we can verify semantic parity.
ast-grep --pattern 'public static function get_domain($$$) { $$$ }'
rg -nP --type=php -C2 "plausible_analytics_settings" -g '!mu-plugin/**'Repository: a8cteam51/plausible-wordpress-plugin Length of output: 25040 🏁 Script executed: rg -n "function get_domain" --type=phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 138 🏁 Script executed: sed -n '230,260p' src/Helpers.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 806 🏁 Script executed: sed -n '100,125p' src/Helpers.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 890 🏁 Script executed: sed -n '105,125p' src/Helpers.phpRepository: a8cteam51/plausible-wordpress-plugin Length of output: 717 Domain value diverges fundamentally: MU plugin returns full URL; Helpers::get_domain() returns bare domain.
If a request payload arrives with domain Additionally, Call 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * @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. | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Query-string REST routing bypasses MU short-circuit.
When pretty permalinks are disabled, WordPress accepts REST requests via
/?rest_route=/{namespace}/v1/.... The path-only check here sees/and treats the request as non-proxy, so all the hardened short-circuit logic is skipped (request still hitssrc/Proxy.php::validate_proxy_request(), so this is not a security regression — just lost early-rejection / speed benefit). Similar note for sites that customizerest_url_prefixaway fromwp-json. Either document the pretty-permalinks assumption or also inspect$_GET['rest_route'].🤖 Prompt for AI Agents