Skip to content

Obscure the Plausible proxy endpoint#300

Open
crweiner wants to merge 7 commits into
plausible:developfrom
a8cteam51:feature/harden-proxy-api
Open

Obscure the Plausible proxy endpoint#300
crweiner wants to merge 7 commits into
plausible:developfrom
a8cteam51:feature/harden-proxy-api

Conversation

@crweiner
Copy link
Copy Markdown

@crweiner crweiner commented May 13, 2026

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.init is 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.php

  • Hide the Plausible proxy namespace and full event route from REST discovery via rest_route_data
  • Return 404 for namespace probing like /wp-json/<namespace>/v1/ via rest_pre_dispatch
  • Replace permission_callback => __return_true with request validation
  • Validate:
    • request body size
    • Content-Type: application/json
    • valid JSON payload
    • same-site Origin or Referer
    • allowed payload keys only
    • expected Plausible domain in d
    • local-site event URL in u
    • p must be an array if present
  • Preserve validation response codes instead of accidentally normalizing them via the response-status shim

mu-plugin/plausible-proxy-speed-module.php

  • Tighten proxy request detection to the /wp-json/<namespace> prefix
  • Short-circuit obvious junk requests before more of WordPress loads
  • Reject:
    • namespace probing
    • wrong path under the namespace
    • non-POST requests
    • non-JSON requests
    • missing same-site provenance
    • invalid payloads
    • oversized bodies
  • Use a capped buffered read of php://input for body-size enforcement instead of trusting Content-Length
  • Align host validation with src/Proxy.php, including relative URLs like /some-page

Thank 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

  • Security Enhancements
    • Added comprehensive request validation for proxy endpoints, including origin and content-type verification
    • Implemented request size limits to prevent oversized payloads
    • Enhanced payload validation to enforce parameter constraints and prevent invalid requests
    • Added protection against probe-like requests targeting proxy endpoints

Review Change Stack

crweiner and others added 7 commits April 9, 2026 12:08
… 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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

The Plausible proxy endpoint is now protected by a multi-layer validation and discovery-hardening system. Both Proxy.php and the MU speed module enforce a request size limit, validate JSON content-type, reject invalid payloads and domains, block namespace probing, and hide routes from REST discovery.

Changes

Proxy Endpoint Security Hardening

Layer / File(s) Summary
Request size limit and core validation pipeline
src/Proxy.php, mu-plugin/plausible-proxy-speed-module.php
MAX_REQUEST_BYTES constant is defined in both files. Proxy.php implements validate_proxy_request as the REST route permission_callback, enforcing body size, JSON Content-Type, valid JSON payload, same-origin Origin/Referer validation (configurable via filter), allowed payload keys, and domain/URL matching against the expected domain and site home host.
REST discovery hardening
src/Proxy.php
rest_pre_dispatch hook blocks GET requests to the namespace index endpoint with a 404; rest_route_data hook removes proxy routes from the /wp-json/ discovery listing to prevent automated endpoint enumeration.
MU plugin initialization and proxy detection
mu-plugin/plausible-proxy-speed-module.php
Speed module constructor loads and caches proxy resources and raw request body from WordPress options. Proxy-request detection is reworked via a new get_proxy_resources() helper and updated is_proxy_request() to route based on parsed request path prefix for /wp-json/<namespace>.
MU plugin request short-circuiting and validation
mu-plugin/plausible-proxy-speed-module.php
init() calls maybe_short_circuit_request() to proactively reject invalid requests with a uniform JSON 404. Supporting helpers build exact endpoint paths, check namespace index vs exact endpoint, extract request method/path/content-type, perform Origin/Referer host-matching provenance checks with domain normalization, enforce request body size, validate JSON payload structure and allowed keys, and enforce domain/URL field constraints.
Response code safety fix
src/Proxy.php
force_http_response_code() safely inspects $response->get_data() and verifies the expected response-code shape before extracting the HTTP status, ensuring validation failures remain 400/404/413 instead of being normalized incorrectly.

Possibly Related PRs

  • plausible/wordpress#297: Touches mu-plugin/plausible-proxy-speed-module.php's proxy detection and filter_active_plugins() logic; both PRs refine endpoint matching and ensure only proxy-related traffic affects plugin filtering.

Poem

🐰 Behold, a proxy wrapped tight with validation care,
No more endpoints dancing bare in /wp-json/ air!
Size checks and tokens guard the door,
While namespaces hide what bots explore.
Security through layers—obscurity's new friend! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Obscure the Plausible proxy endpoint' directly captures the primary objective—hiding and hardening the proxy endpoint to reduce discoverability—and aligns with the main changes in both files.
Linked Issues check ✅ Passed The PR fully implements all five objectives from issue #294: (1) hiding routes from REST discovery via rest_route_data [#294], (2) blocking namespace probing via rest_pre_dispatch [#294], (3) replacing permissive access with strict validation [#294], (4) fixing error status handling [#294], and (5) hardening the MU speed module with early rejection [#294].
Out of Scope Changes check ✅ Passed All changes are directly related to the issue objectives: endpoint obscurity, discovery prevention, request validation, and hardening. No extraneous modifications were introduced outside the scope of #294.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feature/harden-proxy-api

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (4)
mu-plugin/plausible-proxy-speed-module.php (3)

82-90: 💤 Low value

Prefix check is loose but acceptable; subsequent exact-endpoint match catches false positives.

strpos( ..., '/wp-json/' . $namespace ) === 0 will also match a path like /wp-json/<namespace>extra/... (no boundary). With a randomized namespace this collision is extremely unlikely, and is_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 value

Optional: add X-Content-Type-Options: nosniff and cache hints to the error response.

Since this is served from a path that legitimate clients only POST to, adding nosniff and 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 value

Readability: sequence of if { send_rest_no_route(); } relies on exit for control flow.

send_rest_no_route() terminates the request via exit;, so the sequential if blocks work correctly — but the fall-through behavior is implicit, which makes it easy to break later (e.g., if a future change refactors send_rest_no_route() to return instead of exit, every subsequent check would still run on rejected requests). Consider either:

  • Adding an explicit return after 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 satisfy is_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 value

Consider adding an explicit empty-string check for the u parameter to match the MU plugin's approach.

The MU plugin (line 313) explicitly rejects an empty u via $data['u'] === '', whereas Proxy.php line 418 relies on url_matches_home_host('') returning false. 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's get_expected_domain() (both use the same regex to strip scheme and www from home_url(), or return a custom domain setting), and normalize_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

📥 Commits

Reviewing files that changed from the base of the PR and between c2fc480 and e79f252.

📒 Files selected for processing (2)
  • mu-plugin/plausible-proxy-speed-module.php
  • src/Proxy.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Obscured Plausible proxy endpoint is discoverable via /wp-json/

3 participants