Obscure the Plausible proxy endpoint#300
Conversation
… 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>
…es return a 404 so you cant assume an endpoint exists based on response
Added hardening to the host matching and also to ensure that all rout…
- 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.
📝 WalkthroughWalkthroughThe Plausible proxy endpoint is now protected by a multi-layer validation and discovery-hardening system. Both ChangesProxy Endpoint Security Hardening
Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (4)
mu-plugin/plausible-proxy-speed-module.php (3)
82-90: 💤 Low valuePrefix check is loose but acceptable; subsequent exact-endpoint match catches false positives.
strpos( ..., '/wp-json/' . $namespace ) === 0will also match a path like/wp-json/<namespace>extra/...(no boundary). With a randomized namespace this collision is extremely unlikely, andis_exact_proxy_endpoint_request()in the short-circuit pipeline rejects anything that isn't the full path, so behavior is safe — flagging only as a future maintainability note.♻️ Tighten the prefix match to a path-boundary aware check
- return strpos( $this->get_request_path(), '/wp-json/' . $namespace ) === 0; + $prefix = '/wp-json/' . $namespace; + $path = $this->get_request_path(); + + return $path === $prefix || strpos( $path, $prefix . '/' ) === 0;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mu-plugin/plausible-proxy-speed-module.php` around lines 82 - 90, The is_proxy_request() prefix check in the method is_proxy_request currently uses strpos(..., '/wp-json/' . $namespace) === 0 which can match unintended collisions; update is_proxy_request() to ensure a path boundary after the namespace by verifying the request path either equals '/wp-json/' . $namespace or starts with '/wp-json/' . $namespace . '/' (i.e., check for the namespace followed by a slash or end-of-string) so only true namespace-prefixed routes pass through to is_exact_proxy_endpoint_request().
357-368: 💤 Low valueOptional: add
X-Content-Type-Options: nosniffand cache hints to the error response.Since this is served from a path that legitimate clients only POST to, adding
nosniffand a no-store cache header would prevent any intermediary from caching the JSON 404 or sniffing the content type. Not blocking — these responses are short-lived and the body is benign.♻️ Harden the rejection response headers
private function send_json_error( $status, $code, $message ) { status_header( $status ); header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) ); + header( 'X-Content-Type-Options: nosniff' ); + header( 'Cache-Control: no-store' ); echo wp_json_encode( [ 'code' => $code, 'message' => $message, 'data' => [ 'status' => $status ], ] ); exit; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mu-plugin/plausible-proxy-speed-module.php` around lines 357 - 368, The send_json_error function should set stricter response headers to prevent content sniffing and caching; inside send_json_error (the function that currently calls status_header, header('Content-Type: ...'), echo wp_json_encode(...), exit) add header('X-Content-Type-Options: nosniff') and a no-cache/no-store cache header such as header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0') (optionally add header('Pragma: no-cache') and header('Expires: 0') for broader proxies) before echoing the JSON so error responses are not sniffed or cached by intermediaries.
107-135: 💤 Low valueReadability: sequence of
if { send_rest_no_route(); }relies onexitfor control flow.
send_rest_no_route()terminates the request viaexit;, so the sequentialifblocks work correctly — but the fall-through behavior is implicit, which makes it easy to break later (e.g., if a future change refactorssend_rest_no_route()to return instead of exit, every subsequent check would still run on rejected requests). Consider either:
- Adding an explicit
returnafter each rejection, or- Collapsing the checks into a single
if (...) { send_rest_no_route(); }expression.Also, on line 112,
is_namespace_index_request()is redundant: a namespace-index path can never satisfyis_exact_proxy_endpoint_request(), so the second condition already covers it.♻️ Make rejection flow explicit and drop the redundant namespace-index check
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(); - } + if ( + ! $this->is_exact_proxy_endpoint_request() + || $this->get_request_method() !== 'POST' + || ! $this->has_json_content_type() + || ! $this->has_valid_provenance() + || $this->request_body_too_large() + || ! $this->has_valid_payload() + ) { + $this->send_rest_no_route(); + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@mu-plugin/plausible-proxy-speed-module.php` around lines 107 - 135, In maybe_short_circuit_request(): remove the redundant is_namespace_index_request() check and make the rejection flow explicit by collapsing the validations into a single conditional that calls send_rest_no_route() when any validation fails (i.e. if not is_exact_proxy_endpoint_request() OR get_request_method() !== 'POST' OR !has_json_content_type() OR !has_valid_provenance() OR request_body_too_large() OR !has_valid_payload()); keep references to the existing helper methods (is_exact_proxy_endpoint_request, get_request_method, has_json_content_type, has_valid_provenance, request_body_too_large, has_valid_payload) and ensure send_rest_no_route() is the single exit point for failed checks.src/Proxy.php (1)
398-427: 💤 Low valueConsider adding an explicit empty-string check for the
uparameter to match the MU plugin's approach.The MU plugin (line 313) explicitly rejects an empty
uvia$data['u'] === '', whereasProxy.phpline 418 relies onurl_matches_home_host('')returningfalse. While the end result is the same, an explicit check would improve consistency between the two validators and make future maintenance easier.The domain validation is sufficiently robust:
Helpers::get_domain()returns identical logic to the MU plugin'sget_expected_domain()(both use the same regex to strip scheme and www fromhome_url(), or return a custom domain setting), andnormalize_domain()safely handles any minor divergences.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/Proxy.php` around lines 398 - 427, In has_valid_payload add an explicit empty-string check for the 'u' param before calling url_matches_home_host: if $url === '' return false; this should be placed in the URL validation block inside has_valid_payload (keep the existing strlen($url) > 2048 and url_matches_home_host($url) checks) so behavior matches the MU plugin's $data['u'] === '' rejection and improves clarity/consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@mu-plugin/plausible-proxy-speed-module.php`:
- Around line 82-90: The is_proxy_request() prefix check in the method
is_proxy_request currently uses strpos(..., '/wp-json/' . $namespace) === 0
which can match unintended collisions; update is_proxy_request() to ensure a
path boundary after the namespace by verifying the request path either equals
'/wp-json/' . $namespace or starts with '/wp-json/' . $namespace . '/' (i.e.,
check for the namespace followed by a slash or end-of-string) so only true
namespace-prefixed routes pass through to is_exact_proxy_endpoint_request().
- Around line 357-368: The send_json_error function should set stricter response
headers to prevent content sniffing and caching; inside send_json_error (the
function that currently calls status_header, header('Content-Type: ...'), echo
wp_json_encode(...), exit) add header('X-Content-Type-Options: nosniff') and a
no-cache/no-store cache header such as header('Cache-Control: no-store,
no-cache, must-revalidate, max-age=0') (optionally add header('Pragma:
no-cache') and header('Expires: 0') for broader proxies) before echoing the JSON
so error responses are not sniffed or cached by intermediaries.
- Around line 107-135: In maybe_short_circuit_request(): remove the redundant
is_namespace_index_request() check and make the rejection flow explicit by
collapsing the validations into a single conditional that calls
send_rest_no_route() when any validation fails (i.e. if not
is_exact_proxy_endpoint_request() OR get_request_method() !== 'POST' OR
!has_json_content_type() OR !has_valid_provenance() OR request_body_too_large()
OR !has_valid_payload()); keep references to the existing helper methods
(is_exact_proxy_endpoint_request, get_request_method, has_json_content_type,
has_valid_provenance, request_body_too_large, has_valid_payload) and ensure
send_rest_no_route() is the single exit point for failed checks.
In `@src/Proxy.php`:
- Around line 398-427: In has_valid_payload add an explicit empty-string check
for the 'u' param before calling url_matches_home_host: if $url === '' return
false; this should be placed in the URL validation block inside
has_valid_payload (keep the existing strlen($url) > 2048 and
url_matches_home_host($url) checks) so behavior matches the MU plugin's
$data['u'] === '' rejection and improves clarity/consistency.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 45b3a6e6-404a-4028-bc9b-606ea7a4b85c
📒 Files selected for processing (2)
mu-plugin/plausible-proxy-speed-module.phpsrc/Proxy.php
As described in #294, our team has observed botting and DDoS's against the obfuscated Plausible proxy endpoint. To make this endpoint a little harder to find, this PR will hide the Plausible proxy routes from REST discovery.
We do know that
plausible.initis visible in the page source, so removing the API endpoint from appearing in/wp-json/is not a perfect solution, but it might make it a bit harder to automatically find.This PR also adds some other tweaks, as detailed below:
Changes
src/Proxy.phprest_route_data404for namespace probing like/wp-json/<namespace>/v1/viarest_pre_dispatchpermission_callback => __return_truewith request validationContent-Type: application/jsonOriginorRefererdupmust be an array if presentmu-plugin/plausible-proxy-speed-module.php/wp-json/<namespace>prefixphp://inputfor body-size enforcement instead of trustingContent-Lengthsrc/Proxy.php, including relative URLs like/some-pageThank you for taking a look, and do let us know if there is anything we can/should change!
Closes #294
Summary by CodeRabbit
Release Notes