Skip to content

Feature/harden proxy api#1

Open
crweiner wants to merge 5 commits intodevelopfrom
feature/harden-proxy-api
Open

Feature/harden proxy api#1
crweiner wants to merge 5 commits intodevelopfrom
feature/harden-proxy-api

Conversation

@crweiner
Copy link
Copy Markdown
Member

@crweiner crweiner commented Apr 10, 2026

This PR hardens the WordPress proxy endpoint used by the plugin’s “Bypass ad blockers” feature.

The goal is to make the proxy route:

  • less discoverable
  • harder to probe blindly
  • cheaper to reject when abused
  • still functional for legitimate same-site browser traffic

This is not intended to make the endpoint secret from any client that fully loads the page. It is intended to reduce route enumeration and tighten request validation so operators can protect sites more safely.

Problem

The proxy endpoint is randomized, but in the current implementation it is still relatively easy to discover and probe:

  • the randomized route is exposed in WordPress REST discovery
  • the namespace index is publicly readable
  • the route is registered with a permissive permission_callback
  • the MU speed module reduces load, but does not reject obvious junk traffic early enough

That makes the route easier to target than it needs to be, and can force site operators into blunt edge rules that may also block legitimate Plausible tracking.

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

Why these changes

1. Route discovery

The proxy route is randomized, but if it appears in /wp-json/, it is still enumerable. Hiding it from REST discovery removes the most obvious public discovery surface.

2. Namespace probing

Even if the root REST index is hidden, a known namespace should not expose a readable namespace index. Returning 404 makes the route less self-documenting.

3. Request validation

The proxy endpoint is expected to receive anonymous browser traffic, but it does not need to accept arbitrary malformed traffic. Tightening validation helps reject obvious junk early.

4. Early rejection in the MU layer

The MU speed module is the cheapest place to reject bad proxy traffic. The more validation that can happen there, the less unnecessary WordPress work happens under load.

Verified behaviors

With proxy enabled:

  • /wp-json/ does not list the generated Plausible namespace/route
  • GET /wp-json/<namespace>/v1/ returns 404
  • the frontend still embeds the exact endpoint in plausible.init({ endpoint: ... })
  • valid same-site JSON POST to the full endpoint returns 202
  • valid same-site JSON POST with a relative URL like "u": "/test" also returns 202
  • missing Origin / Referer returns 404
  • foreign Origin / Referer returns 404
  • wrong domain in d returns 400
  • malformed JSON returns 400
  • disallowed payload keys return 400
  • p as a non-array returns 400
  • wrong path under the namespace returns 404
  • oversized valid JSON body returns 413

Additional behavior checks

  • With proxy disabled:
    • the local proxy endpoint stops working
    • the frontend stops overriding the endpoint
  • Without the MU plugin installed:
    • the route still works
    • route hiding still works
    • the plugin just loses the earliest short-circuit layer
  • Proxy resources can still be rotated by clearing/regenerating the stored proxy resources

Notes

  • OPTIONS / preflight requests now return 404. This is acceptable for the intended same-origin browser use case, but worth being aware of if cross-origin proxy usage is ever expected.
  • This PR does not attempt to hide the endpoint from clients that actually load the page. The endpoint still has to be present in frontend configuration for the browser to use it.
  • The purpose of this PR is to remove REST-level discoverability and tighten server-side acceptance rules, not to provide perfect secrecy.

Resolves plausible#294

Summary by CodeRabbit

  • New Features

    • Proxy endpoint now enforces maximum request size limits
    • Route discovery is now hidden from API output when proxy is enabled
    • Namespace probing requests return uniform 404 responses
  • Bug Fixes

    • Stricter payload validation and same-origin verification for proxy requests
    • Enhanced request path matching for improved endpoint accuracy

@crweiner
Copy link
Copy Markdown
Member Author

@copilot, please review this PR

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

Copilot AI commented Apr 10, 2026

@copilot, please review this PR

I reviewed the PR and found two inconsistencies between the Proxy.php and MU plugin validation layers. Fixed in 06555ad:

  1. url_matches_home_host() in Proxy.php was missing an explicit if (!$host) return false; guard for non-parseable, non-relative URLs. The MU plugin had this correctly. While safe in practice (empty string won't match a real domain), the implicit path through normalize_domain(null) was unnecessarily fragile.

  2. MU plugin's has_valid_payload() was missing the strlen($data['u']) > 2048 URL length check that exists in Proxy.php. Added for consistency.

Beyond those two fixes, the rest of the implementation looks solid:

  • Route discovery hiding via rest_route_data and namespace index blocking via rest_pre_dispatch are clean
  • The force_http_response_code null-safety improvement correctly prevents status code clobbering when validation errors return WP_Error
  • The MU module's capped file_get_contents('php://input', ..., MAX_REQUEST_BYTES + 1) is a good approach for body-size enforcement
  • The defense-in-depth of duplicating validation across both layers is appropriate since the MU plugin may not always be installed
  • The plausible_analytics_proxy_require_same_origin and plausible_analytics_proxy_max_body_bytes filters provide useful escape hatches

One pre-existing note (not introduced by this PR): do_request() on line 112 constructs a path with "/$this->namespace/v1/$this->base/$this->endpoint" but $this->namespace already includes /v1, producing a doubled /v1/v1/ segment. This doesn't cause issues since do_request() calls send_event() directly and the path is never routed, but it's worth cleaning up separately.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/d95a9d895352c30f47604ad1b825ab8fa9d1a373
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/41aaac462fbd80feb8dd129e489f4bbc53fe26b0
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/Yoast/wp-test-utils/zipball/2e0f62e0281e4859707c5f13b7da1422aa1c8f7b
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/coenjacobs/mozart/zipball/dbcdeb992d20d9c8914eef090f9a0d684bb1102c
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/composer/installers/zipball/12fb2dfe5e16183de69e784a7b84046c43d97e8e
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/php-fig/log/zipball/ef29f6d262798707a9edd554e2b82517ef3a9376
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/php-parallel-lint/PHP-Console-Color/zipball/7adfefd530aa2d7570ba87100a99e2483a543b88
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/php-parallel-lint/PHP-Console-Highlighter/zipball/5b4803384d3303cf8e84141039ef56c8a123138d
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/php-parallel-lint/PHP-Parallel-Lint/zipball/6db563514f27e19595a19f45a4bf757b6401194e
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/symfony/string/zipball/50590a057841fa6bf69d12eceffce3465b9e32cb
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/thephpleague/flysystem/zipball/3239285c825c152bcc315fe0e87d6b55f5972ed1
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)
  • https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/GwJoOV /usr/bin/composer install --no-interaction (http block)

If you need me to access, download, or install something from one of these locations, you can either:

gin0115 added 2 commits April 15, 2026 14:03
…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…
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

Walkthrough

Changes were made to both the mu-plugin/plausible-proxy-speed-module.php file and src/Proxy.php file to add request validation and security hardening. A new constant MAX_REQUEST_BYTES = 8192 was introduced across both files. Request-path matching logic was refined with stricter prefix checks using parsed request paths. Multiple validation methods were added to enforce request body size limits, require application/json Content-Type, validate JSON payload structure, verify same-origin provenance via Origin and Referer headers, and whitelist payload fields with constraints. Additionally, REST API filtering hooks were introduced to reject namespace route probing and hide proxy routes from REST discovery output.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feature/harden proxy api' accurately reflects the main objective of the PR, which is to harden the WordPress proxy endpoint with security and discoverability improvements.
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 docstrings
  • Create stacked PR
  • Commit on current branch

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

@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.

Actionable comments posted: 5

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

107-135: Short-circuit sequence relies on a non-obvious exit inside the helper.

Each check calls send_rest_no_route() which only terminates because send_json_error() ends in exit. If that helper is ever refactored to return or throw, the subsequent checks below would continue executing and a subsequent header()/echo would trip "headers already sent" / double output. Returning after the call (or converting to elseif) makes the contract explicit and robust.

♻️ Proposed fix
-		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_namespace_index_request()
+			|| ! $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 the current code and only fix it if needed.

In `@mu-plugin/plausible-proxy-speed-module.php` around lines 107 - 135, The
maybe_short_circuit_request() function relies on send_rest_no_route() to
terminate execution implicitly (send_json_error() currently calls exit); make
this explicit and robust by returning immediately after each
send_rest_no_route() call (or restructure the checks into an if/elseif chain) so
subsequent checks won't run if a short-circuit response was sent; update
maybe_short_circuit_request() to call send_rest_no_route() followed by return
and keep references to send_rest_no_route() and send_json_error() for clarity.

82-90: Prefix check lacks a boundary — potential over-match.

strpos($path, '/wp-json/' . $namespace) === 0 will also match siblings like /wp-json/{namespace}something/... if some future route happens to share the prefix. With the randomized namespace this is extremely unlikely to collide in practice, but it's cheap to make the check exact by requiring the next character to be / or end-of-string.

♻️ Proposed tightening
-		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 the current code and only fix it if needed.

In `@mu-plugin/plausible-proxy-speed-module.php` around lines 82 - 90, The prefix
check in is_proxy_request currently allows over-matching; update
is_proxy_request to compute $prefix = '/wp-json/'.$namespace, verify the request
path starts with that prefix (using strpos or strncmp), then ensure the
character immediately after the prefix is either a slash or there is no
character (path length equals prefix length). Use get_request_path() and
resources['namespace'] to locate values; return false if the prefix doesn't
match or the next character is neither '/' nor end-of-string, otherwise return
true.
src/Proxy.php (1)

398-427: Consider validating revenue shape.

revenue is whitelisted but otherwise passes through to send_event() unchecked. Plausible expects { currency: string, amount: number|string } here — if you care about defense-in-depth against a misbehaving tracker or an attacker forging a same-origin request, a light shape check (array with currency string and numeric amount) would keep the forwarded body tight. Not blocking if you'd rather stay permissive and let Plausible reject upstream.

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

In `@src/Proxy.php` around lines 398 - 427, The payload validator currently allows
a `revenue` field through unchecked; update has_valid_payload to also validate
`revenue` (before returning true) when set: ensure isset($params['revenue'])
implies is_array($params['revenue']) with keys 'currency' and 'amount', that
'currency' is a non-empty string and 'amount' is a numeric value (or numeric
string) — reject otherwise; keep this check inside has_valid_payload so only
well-shaped revenue objects are forwarded to send_event.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mu-plugin/plausible-proxy-speed-module.php`:
- Around line 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.
- Around line 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.
- Around line 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.
- Around line 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.

In `@src/Proxy.php`:
- Around line 489-496: The current check uses empty( $data['response']['code'] )
which can trigger PHP 8 warnings if $data['response'] is a scalar; update the
guard to ensure $data['response'] is an array before accessing ['code'].
Specifically, in the block after $data = $response->get_data() change the
conditional to verify is_array($data) && isset($data['response']) &&
is_array($data['response']) && ! empty($data['response']['code']) (or the
logical inverse to return early), so wp_remote_retrieve_response_code($data) and
$response->set_status(...) only run when $data['response']['code'] is safely
accessible.

---

Nitpick comments:
In `@mu-plugin/plausible-proxy-speed-module.php`:
- Around line 107-135: The maybe_short_circuit_request() function relies on
send_rest_no_route() to terminate execution implicitly (send_json_error()
currently calls exit); make this explicit and robust by returning immediately
after each send_rest_no_route() call (or restructure the checks into an
if/elseif chain) so subsequent checks won't run if a short-circuit response was
sent; update maybe_short_circuit_request() to call send_rest_no_route() followed
by return and keep references to send_rest_no_route() and send_json_error() for
clarity.
- Around line 82-90: The prefix check in is_proxy_request currently allows
over-matching; update is_proxy_request to compute $prefix =
'/wp-json/'.$namespace, verify the request path starts with that prefix (using
strpos or strncmp), then ensure the character immediately after the prefix is
either a slash or there is no character (path length equals prefix length). Use
get_request_path() and resources['namespace'] to locate values; return false if
the prefix doesn't match or the next character is neither '/' nor end-of-string,
otherwise return true.

In `@src/Proxy.php`:
- Around line 398-427: The payload validator currently allows a `revenue` field
through unchecked; update has_valid_payload to also validate `revenue` (before
returning true) when set: ensure isset($params['revenue']) implies
is_array($params['revenue']) with keys 'currency' and 'amount', that 'currency'
is a non-empty string and 'amount' is a numeric value (or numeric string) —
reject otherwise; keep this check inside has_valid_payload so only well-shaped
revenue objects are forwarded to send_event.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ff7257bc-c655-482b-926a-3af00dd25f60

📥 Commits

Reviewing files that changed from the base of the PR and between 558eddb and 9786201.

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

Comment on lines +149 to +151
private function get_request_path() {
return wp_parse_url( $this->request_uri, PHP_URL_PATH ) ?: '';
}
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.

Comment on lines +197 to +214
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;
}
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.

Comment on lines +305 to +315
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;
}
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.

Comment on lines +340 to +348
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();
}
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.

Comment thread src/Proxy.php
Comment on lines +489 to 496
$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 );
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

Tighten the response subarray check to avoid PHP 8 warnings.

empty( $data['response']['code'] ) will emit a "cannot access offset on string" warning on PHP 8+ if $data['response'] happens to be a scalar rather than an array. Adding an is_array() guard on the intermediate offset keeps this fully warning-free and matches the intent of the existing is_array( $data ) check.

🛡️ Proposed fix
-		if ( ! is_array( $data ) || empty( $data['response']['code'] ) ) {
+		if ( ! is_array( $data ) || ! isset( $data['response'] ) || ! is_array( $data['response'] ) || empty( $data['response']['code'] ) ) {
 			return $response;
 		}
PHP 8 warning for array access with non-numeric string key on a scalar value
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Proxy.php` around lines 489 - 496, The current check uses empty(
$data['response']['code'] ) which can trigger PHP 8 warnings if
$data['response'] is a scalar; update the guard to ensure $data['response'] is
an array before accessing ['code']. Specifically, in the block after $data =
$response->get_data() change the conditional to verify is_array($data) &&
isset($data['response']) && is_array($data['response']) && !
empty($data['response']['code']) (or the logical inverse to return early), so
wp_remote_retrieve_response_code($data) and $response->set_status(...) only run
when $data['response']['code'] is safely accessible.

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