Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 299 additions & 2 deletions mu-plugin/plausible-proxy-speed-module.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
*/

class PlausibleProxySpeed {
const MAX_REQUEST_BYTES = 8192;

/**
* Is current request a request to our proxy?
*
Expand All @@ -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();

Expand All @@ -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;
}

/**
Expand All @@ -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 ) ?: '';
}
Comment on lines +149 to +151
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 hits src/Proxy.php::validate_proxy_request(), so this is not a security regression — just lost early-rejection / speed benefit). Similar note for sites that customize rest_url_prefix away from wp-json. Either document the pretty-permalinks assumption or also inspect $_GET['rest_route'].

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mu-plugin/plausible-proxy-speed-module.php` around lines 149 - 151, The
get_request_path() helper currently only returns the path from
$this->request_uri which misses REST requests sent via query string (e.g.
?rest_route=/namespace/v1/...) or when rest_url_prefix is customized; update
get_request_path() to first check for a $_GET['rest_route'] value and, if
present, return that route's path (normalized/trimmed of leading slash),
otherwise fall back to wp_parse_url($this->request_uri, PHP_URL_PATH) ?: ''. Use
rest_get_url_prefix() if you need to normalize or detect prefixed routes so
query-string REST requests are treated the same as pretty-permalink ones.


/**
* @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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 5

Repository: 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.php

Repository: 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 -i

Repository: 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.php

Repository: 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.php

Repository: 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.php

Repository: 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 5

Repository: 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.php

Repository: 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 -20

Repository: 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 -40

Repository: 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.php

Repository: 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.php

Repository: 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 -10

Repository: 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 -10

Repository: 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 -20

Repository: 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 -50

Repository: 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 -20

Repository: 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 -30

Repository: 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 -50

Repository: a8cteam51/plausible-wordpress-plugin

Length of output: 624


Filter has no registered callbacks and is effectively unused.

apply_filters( 'plausible_analytics_proxy_require_same_origin', true ) is applied on every proxy request in both the MU layer and src/Proxy.php, but the filter is never registered with a callback anywhere in the codebase. This means the default value of true is always used, making the filter dead code. If this filter is intended for users to override, either register default callbacks in the plugin initialization or document the requirement to hook it from wp-config.php or another MU plugin.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mu-plugin/plausible-proxy-speed-module.php` around lines 197 - 214, The
apply_filters call for 'plausible_analytics_proxy_require_same_origin' in
has_valid_provenance is effectively unused because no callback is registered;
either register a default filter callback during plugin initialization or remove
the filter. Fix by adding a single, documented add_filter registration in your
MU-plugin bootstrap (or plugin init hook) that returns the desired boolean
(e.g., respects a defined constant or option) so apply_filters in
has_valid_provenance() and the corresponding call in src/Proxy.php are honorably
overridable, or alternatively remove the filter usage from
has_valid_provenance() and src/Proxy.php if you decide not to support
overriding.


/**
* 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

d field is passed to normalize_domain() without a type guard — TypeError on non-string payloads.

empty( $data['d'] ) returns false for a non-empty array/object, so execution falls into normalize_domain( $data['d'] ), which calls trim( strtolower( $domain ) ) on a non-string. On PHP 8+ this raises a TypeError/fatal and turns a malformed probe into a 500, defeating the uniform-404 design (and giving scanners a signal). src/Proxy.php::has_valid_payload() correctly guards with is_string() first — mirror that here.

Also worth aligning n/u to the same is_string + '' pattern used in src/Proxy.php so both layers accept/reject identical inputs (e.g. "n": "0" is currently rejected by MU via empty() but accepted by Proxy.php).

🐛 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
Verify each finding against the current code and only fix it if needed.

In `@mu-plugin/plausible-proxy-speed-module.php` around lines 305 - 315, The
current checks call normalize_domain() on $data['d'] and other string functions
without guarding types, which can cause TypeError on non-strings; update the
validation in plausible-proxy-speed-module.php to mirror src/Proxy.php by
requiring is_string() for 'n', 'd', and 'u' before any string operations: for
'n' require is_string($data['n']) and $data['n'] !== '' and strlen(...) <= 120;
for 'd' require is_string($data['d']) before calling
normalize_domain($data['d']) and comparing to
normalize_domain($this->get_expected_domain()); for 'u' require
is_string($data['u']) before strlen(...) and before calling
$this->url_matches_home_host($data['u']); keep the same length limits and
url_matches_home_host/normalize_domain/get_expected_domain checks otherwise so
both layers accept/reject identical inputs.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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=php

Repository: a8cteam51/plausible-wordpress-plugin

Length of output: 138


🏁 Script executed:

sed -n '230,260p' src/Helpers.php

Repository: a8cteam51/plausible-wordpress-plugin

Length of output: 806


🏁 Script executed:

sed -n '100,125p' src/Helpers.php

Repository: a8cteam51/plausible-wordpress-plugin

Length of output: 890


🏁 Script executed:

sed -n '105,125p' src/Helpers.php

Repository: a8cteam51/plausible-wordpress-plugin

Length of output: 717


Domain value diverges fundamentally: MU plugin returns full URL; Helpers::get_domain() returns bare domain.

Helpers::get_domain() (src/Helpers.php:234) falls back to preg_replace( '/^http(s?):\/\/(www\.)?/i', '', home_url() ), stripping scheme and www to return a bare domain like example.com. In contrast, get_expected_domain() returns home_url() directly, which includes the full URL scheme and potentially path (e.g., https://www.example.com/ or https://www.example.com/subdir).

If a request payload arrives with domain example.com and get_expected_domain() returns https://www.example.com/, the validation fails in the MU plugin before the request reaches Proxy.php::has_valid_payload(), which would have accepted it. This breaks tracking silently.

Additionally, get_expected_domain() bypasses the filter hook plausible_analytics_settings and does not use default values, creating a secondary divergence from the standard settings retrieval path.

Call Helpers::get_domain() directly if the MU plugin can safely load it, or extract and reuse its domain normalization logic to ensure parity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mu-plugin/plausible-proxy-speed-module.php` around lines 340 - 348,
get_expected_domain() currently returns home_url() (including scheme/path) and
skips the plausible_analytics_settings filter, causing mismatch with
Helpers::get_domain() which normalizes to a bare domain; update
get_expected_domain() to obtain settings via
get_option('plausible_analytics_settings', []) (respecting existing
filter/defaults) and then either call Helpers::get_domain() to normalize the
domain or replicate its normalization (preg_replace to strip ^https?://(www\.)?)
so the function returns a bare domain like example.com matching
Helpers::get_domain() and avoiding validation divergence.


/**
* @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.
*
Expand Down
Loading