
Somewhere in the Plausible Analytics WordPress plugin is a small performance shortcut. When a visitor’s browser pings the analytics proxy, the plugin unloads every other plugin on the site for the duration of that one request, on the reasoning that a tracking pixel does not need WooCommerce or a page builder or an SEO plugin loaded to do its job. It shaves a few milliseconds off the request. It is also, until version 2.5.8, a way for an anonymous attacker to switch off your firewall.
We found this in late April 2026, reported it to Plausible, watched them patch it, and are writing it up now that a fixed version is live on wordpress.org. The finding is filed under our News desk. It is not a data-exposure bug, and it leaks no analytics. What it does is stranger: a feature whose whole job is to turn other plugins off, reachable by anyone, with no password and no exploit chain beyond a query string.
The feature
Plausible is a privacy-focused analytics tool, and one of the reasons people install its WordPress plugin is the first-party proxy. Ad blockers and content blockers keep long lists of known analytics endpoints. Serving the tracking script and the event collector from your own domain, under a REST route on your own site, sidesteps those lists. The plugin calls this the proxy, and it registers a REST namespace to carry the traffic.
To keep that proxy fast, the plugin ships a must-use plugin. Must-use plugins live in wp-content/mu-plugins/, load before everything else, and cannot be switched off from the admin screen. This one is named plausible-proxy-speed-module.php, and it installs itself automatically when you enable the proxy. Its job is narrow. On a request it believes is headed for the proxy, it hooks the active-plugin list and strips it down to Plausible alone. Nothing else runs for that request. The tracking event gets recorded against a nearly empty WordPress, which is quick.
The intent is defensible. The problem is entirely in how the module decides which requests count as proxy requests.
The bug
Two things went wrong in the same file, and they compound.
First, the check that answers “is this a proxy request” used an unanchored substring match against the raw request URI. The original code, in effect:
private function is_proxy_request() {
$namespace = get_option( 'plausible_analytics_proxy_resources' )['namespace'] ?? '';
if ( ! $namespace ) {
return false;
}
return strpos( $this->request_uri, $namespace ) !== false;
}
strpos returns a match when the namespace appears anywhere in the URI. Not as a path segment, not under the REST prefix, anywhere. A legitimate proxy call to /wp-json/<namespace>/event matches. So does /?anything=<namespace>. The plugin never checks that the string it is looking for sits in a structurally meaningful place.
Second, the string it looks for is not a secret. The namespace is the plugin’s REST API route, and it is printed in clear text in the HTML of every page that loads the tracking script, inside the init call:
plausible.init({"endpoint":"https://example.com/wp-json/<namespace>"});
View source on any page. There it is. So the value that gates the plugin-unloading behavior is published, by design, on the front end of every site running the proxy.
Put the two together. An attacker reads the namespace off the page source, appends it to a URL as a junk query parameter, and the speed module concludes the request is bound for the proxy and unloads every plugin on the site for that request. Security plugins included.
What we tested
We validated this on a live install of our own, which we will call target.example. It ran Wordfence in the basic PHP protection mode, the default for a lot of sites that install Wordfence and never touch the server config. Around 30 plugins were active, the usual production mix: a page builder, an SEO plugin, a caching layer, a forms plugin, and Wordfence itself.
One thing tripped us up early, and it is worth passing on. In basic mode, Wordfence serves its block page with an HTTP 200 status, not a 403. The block is in the response body, not the status line. If you test for a WAF bypass by watching the status code, basic-mode Wordfence looks like it is failing open when it is doing its job. The reliable signal is response size.
The baseline, a path-traversal payload Wordfence blocks:
curl -sSL "https://target.example/?file=/etc/passwd" | wc -c
358 (Wordfence block page)
The same payload, with the namespace bolted on as an unrelated parameter:
curl -sSL "https://target.example/?file=/etc/passwd&ns=<namespace>" | wc -c
6 (no block; every plugin but Plausible unloaded)
The block was gone because Wordfence was gone. It never loaded for that request. To confirm the mechanism rather than the symptom, we dumped the active-plugin list on a normal request and on a namespace-tagged one:
- A normal homepage request rendered the full page, around 438 KB, with the full plugin set loaded.
- The same request with the namespace appended rendered a broken 49 KB shell, with a single plugin loaded: Plausible.
The roughly 89 percent drop in page weight is the absence of everything that makes the site work, including the firewall. Because the match is unanchored, you do not even need the whole namespace. Any substring of it is enough. A short leading fragment does the job.
Why it matters, and to whom
The finding does not compromise a site by itself. It removes the things that were supposed to stop a site being compromised. That makes it a force multiplier rather than a headline, and the severity depends entirely on what a given site leans on.
If your security posture rides on plugin hooks, this request-scoped off switch reaches most of it. WAF rules that run inside WordPress, brute-force and rate limiting on the login and XML-RPC endpoints, login-time two-factor enforced by a plugin, consent and cookie gating that a compliance plugin adds to the page, caching that keeps a request from hitting PHP and the database at all. Each of those is a plugin, and each of those unloads on a namespace-tagged request. A future vulnerability in some unrelated plugin, mitigated today by a virtual patch that a firewall applies, becomes exploitable again on any request an attacker chooses to tag.
There is one meaningful exception. Wordfence also ships an extended protection mode that runs as an auto_prepend_file, before WordPress boots at all. That layer survives, because it never goes through the plugin loader. The WAF portion of the bypass does not touch it. Wordfence’s plugin-level features, login security and live traffic and the rest, still unload even in extended mode, because those are ordinary plugin code. The lesson repeats the one we keep making in our plugin coverage: a control that runs before WordPress is worth more than the same control running inside it.
The fix
The correct fix stops scanning the raw URI and anchors the check to the request path. Parse the path out, work out what the proxy path actually is for the configured namespace, and require the request to match it as a real path segment. Our proposed version:
private function is_proxy_request() {
$namespace = get_option( 'plausible_analytics_proxy_resources' )['namespace'] ?? '';
if ( ! $namespace ) {
return false;
}
$path = parse_url( $this->request_uri, PHP_URL_PATH );
if ( ! is_string( $path ) || $path === '' ) {
return false;
}
$rest_prefix = function_exists( 'rest_get_url_prefix' )
? trim( rest_get_url_prefix(), '/' )
: 'wp-json';
$expected = '/' . $rest_prefix . '/' . trim( $namespace, '/' );
return $path === $expected
|| strpos( $path, $expected . '/' ) === 0;
}
Now the namespace only counts when it shows up as an actual segment under the REST prefix. /?file=/etc/passwd&ns=<namespace> has a path of /, which is not the proxy path, so the module leaves every plugin loaded and Wordfence does its job.
Plausible’s merged fix took the same path-anchoring route and improved on the details. It builds the expected path with rest_get_url_prefix() and core URL parsing rather than string concatenation, which handles custom REST prefixes and subdirectory installs cleanly, and it uses str_starts_with() for the segment check. It also tightened the companion allowlist that decides which plugins to keep, swapping a second substring test for an exact filename match. The change landed in pull request 297 on 5 May 2026, which bumps the speed module to version 1.0.1. A follow-up went after the root of the discoverability problem by obscuring the proxy endpoint so the namespace is harder to harvest in the first place.
One detail in the release deserves a callout, because it is the part sites will miss. The vulnerable file lives in mu-plugins/, and updating the parent plugin does not, on its own, replace a file that is already sitting there. Plausible handled this by adding an upgrade routine that reinstalls the module when the plugin updates to 2.5.8. The fixed parent version is therefore 2.5.8. If you run the Plausible proxy, updating to 2.5.8 is the fix. Confirm the module in wp-content/mu-plugins/plausible-proxy-speed-module.php reports version 1.0.1 afterward.
How the disclosure went
Reasonable, mostly. We reported to Plausible’s security address in late April. We noted in the report that the WordPress plugin lives in a separate repository from the Elixir core product their disclosure policy names, so it sat technically outside the stated scope, and that we were reporting anyway because it is an officially published Plausible component. That is the honest way to handle a scope edge, and it is worth naming for anyone weighing whether to report something that falls in a gap: report it, say why it is a gap, and let the vendor decide.
Plausible acknowledged the same day and forwarded it internally. A few days later they asked for the patch, and we sent the diff and the full patched file. About a week after that they replied that a fix was implemented and pointed at the merged pull request. We reviewed the diff and confirmed it closes the bypass. On the timeline, they were responsive and they shipped. Credit where it is due.
Two threads did not tie off as cleanly. We submitted the finding to Patchstack, whose database is one of the standard homes for WordPress vulnerability records, and it was rejected on the bounty side as “not enough impact to be accepted.” We think that is a defensible bounty call and a poor description of the bug. A bounty triager is looking for direct impact on the affected plugin’s own data. This bug’s impact is indirect: it disables other plugins, and it matters most when a security plugin is present, which reads as conditional. A rejected bounty is not an invalid finding, and the working firewall bypass on a production site settles the question of whether it is real. This is the recurring problem with scoring force-multiplier bugs. CVSS does not have a clean box for “quietly turns off your other defenses.”
The other loose thread is a CVE. The fix is public in a merged pull request, and a public patch with no identifier is the worst of both worlds: attackers can read the change, defenders have nothing to track. Since the technical detail is now safe to publish, with 2.5.8 live and the vulnerable population able to update, we are coordinating a CVE through Wordfence as a CNA. If it assigns, we will add the identifier here.
The pattern worth watching
The single bug is fixed. The category it belongs to is not going anywhere, and it is the reason this one is worth more than a one-line advisory.
Almost every vulnerability scanner looks at one plugin in isolation. It reads that plugin’s code, checks its inputs, models its behavior alone. What it does not model is the seam between plugins: a caching layer that short-circuits a firewall, a plugin whose REST routes collide with another’s permission checks, and this case, a plugin that by design disables other plugins for a class of requests. Those bugs need a running, realistically loaded stack to surface, which is exactly what isolated scanning does not provide. It is the same blind spot that let the Essential Plugin backdoor live in auto-updated code for eight months: the ecosystem trusts the boundary between one plugin and the next further than the boundary deserves.
The research angle, for anyone who wants to pull the thread: look for plugins that manipulate the active-plugin set, short-circuit request handling, or otherwise change what runs for some subset of requests. Then ask which of your security controls ride on the things they turn off. Plausible’s speed module is the clean prototype of that class. It will not be the last one we write up.
If you want the defensive version of this, our guide to securing a WordPress site makes the case for defense that does not live entirely in the plugin layer, and our Wordfence review covers why extended protection mode is worth the extra setup. The short version fits in a sentence: do not let one plugin be the only thing standing between an attacker and your site, and do not let another plugin be able to switch it off.