Super Speedy Filters security review
Security › Super Speedy Filters
Security score 100/100 · Version 6.67.6 · Reviewed 6 November 2026 · Source fingerprint af4fba8
Summary
Super Speedy Filters was put through a full adversarial security review in November 2026. The review is run by an AI agent against a written checklist, and every finding is reproduced on private test sites before any change is made. The plugin started the review at 0 out of 100 and now scores 100 out of 100 on commit af4fba8, version 6.67.6. Reaching that score took seventeen review rounds. Each round re-read the whole plugin, reproduced what it found, and the fix for every finding was proven with a test that failed before the change and passed after it. No feature was removed to gain points.
The review scores only what could affect a customer’s own site. Developer notes and material that exists only in the plugin’s private source repository, and never in a release, are listed as housekeeping rather than counted against the score.
What was found and fixed
The findings are grouped by review round, oldest first. Every one is fixed in the version named against it; all of those versions are in the current release.
Rounds 1 to 3 (fixed in 6.66.3, 6.66.4 and 6.66.5)
The first rounds found a cluster of input- and output-handling issues:
- Stored values printed into HTML without escaping. Filter widget settings placed through blocks or the auto-classifier (display type, step, meta key, slider bounds, group title, icon, CSS classes and legacy colour values) reached HTML attributes without escaping, so a user who could place a block but not normally add HTML could store script. Fixed by escaping at every output point and validating colour values.
- A group title read into the page as HTML by the front-end script. Fixed by setting it as text.
- Fragment AJAX could be pointed at another site. A crafted
#ssf=state could resolve to a different origin, so the background request or a navigation went off-site. Fixed by resolving against the page’s own origin and refusing anything else. - Any product meta key could be filtered on from the address, which let an anonymous visitor read a protected value such as cost of goods one step at a time. Fixed by accepting a protected (underscore-prefixed) meta key only when a configured filter actually uses it.
- The Fast Ajax plugin trim ran on any front-end address. A page protected by another plugin could render without that plugin, and a trimmed POST could reach WooCommerce form handlers without the plugins that guard them. Fixed so a trimmed request that is not a real filter listing reloads with every plugin, and the trimmed product request carries no form body.
- Performance hooks on core term and quick-edit actions had no capability check, so any logged-in user could queue rebuilds. Fixed by requiring, for each action, the capability WordPress itself requires.
- The classifier wrote raw widget instances, skipping the sanitising the widget normally applies. Fixed by running the values through it first.
- The MCP get/configure-widget abilities accepted any post ID. Fixed so they act only on template posts the caller may edit, and slashes are preserved.
- The filter-CSS guard against breaking out of the style element was single-pass. Fixed by removing the character outright.
- Settings import stored SEO-override HTML unfiltered while the editor filtered it. Fixed by filtering on import unless the user may add unfiltered HTML.
- Hardening: the background-process handler wrote to the error log before checking its nonce; the nonce is now checked first.
- Dot-segment and encoded
#ssf=states, REST addresses outside/wp-json/, Active Filters and items-per-page sinks, an author’s post opening a protected meta key, and imported override titles were all found by independent re-review and fixed. - The Fragment AJAX widget-route prefix trimmed any request that carried its query variable (ordinary pages, REST, WooCommerce AJAX). Fixed, along with raw inline-filter classes and an edit path that recorded a post’s key.
Rounds 4 to 6 (fixed in 6.66.6, 6.66.7 and 6.66.8)
- Encoded and dot-segment paths still trimmed administrative scripts such as
xmlrpc.phpandwp-login.php. Fixed so the trim acts only under the theme front controller, with a same-origin guard on every untrim. - A same-origin uploaded file could be parsed into the page; a classifier error could appear in page content; the plugin’s active-plugin list leaked; every search counted as a listing;
total_saleswas probeable. All fixed. post_type=productmade author archives and core sitemaps count as listings, and a repeated marker could loop. Fixed.
Structural change in 6.66.9
After six rounds each found another way into the Fast Ajax trim, the trim was rebuilt to act only on addresses that are genuinely filter listings, compiled from the site’s own configuration. The speed trade-offs of that change are measured and recorded in the plan.
Rounds 7 to 12 (fixed in 6.66.10 through 6.66.16)
- A guard let
//x/shop/through the listing matcher, caught downstream; tightened anyway (6.66.10). - The archive-gate bypass token sat in a web-readable policy file. The file now holds only a hash of the token (6.66.11).
- The widget colour panel in the admin showed term data as markup. Fixed, with the surrounding settings list hardened the same way (6.66.12).
- A deliberate sweep of every admin script plus stored-value paths found several places where settings keys, stored post types and SEO-override fields reached the page unescaped. Fixed (6.66.13).
- The raw search query reached the generated SEO title and so the page heading (reflected cross-site scripting when generated titles are configured); numeric settings were stored and printed raw; several WooCommerce GET handlers ran trimmed. Fixed (6.66.15).
- The raw search query reached the breadcrumb trail, which some SEO plugins print without escaping; a WooCommerce checkout link ran trimmed; every distinct search wrote a cache file. Fixed (6.66.16).
Complete sink inventory, fixed in 6.66.17
After round 12 the whole plugin was enumerated line by line into a written inventory of every point where data is output, every address built, every request entry point and every stored value read back into a page. Eleven issues were reproduced and fixed in 6.66.17: an array-valued filter parameter that crashed a listing page; a settings parameter name that could collide with an internal query variable and reach the product query; cache files written per address spelling; the request Host header appearing in cached widget links; unescaped widget titles and labels; the range-widget form; developer-option constants; settings-import validation; filter-CSS escapes; translated colour swatches; and front-end selectors built from page data.
Round 13 (fixed in 6.66.19)
Scored 0 out of 100 against the inventory.
- SECURITY-001 (Critical). On WordPress 6.2 to 6.9, where the block editor canvas is not isolated, the Dashicons icon picker built an element from a value an author could place in post content, so script could run in an administrator’s editor. Fixed so the picker accepts only an element id.
- SECURITY-002 (High). The Fragment widget route accepted a query string, folded it into the render, and cached that render for every visitor under the address. Fixed so the route refuses a query string.
- SECURITY-003 (Medium). A line break inside a quoted string in custom filter CSS let an unscoped rule hide behind it. Fixed by refusing such CSS.
- SECURITY-004 (Medium). Adding the widget-route query variable to any address switched off the archive request limits while the main query still ran. Fixed so the variable comes only from the route’s own rewrite rule.
- SECURITY-005 (Low). The earlier cache-key fix was incomplete; extra slashes in an address still wrote new cache files. Fixed.
- SECURITY-006 (Low). The bundled plugin-recommendation library rendered plugin information to anonymous requests. Fixed by requiring the install-plugins capability.
- SECURITY-007 (Low). Private product taxonomies, such as WooCommerce’s visibility taxonomy, could be filtered on from the address and their term names shown back. Fixed so only viewable taxonomies and WooCommerce attributes are read from the address, with an opt-in filter for a site that needs another.
- SECURITY-008 (Low). The Fragment AJAX configuration file was readable over the web. It is now a file the web server does not serve.
- SECURITY-009 and SECURITY-010 (no points). A postmeta label selector was built from page data (now escaped), and the internal-query-variable list missed a few names (now complete).
Before round 14, fixed in 6.67.2
A re-verification of every output row, every inventory gap and the code added since round 13 found and fixed: the search-keyed widget cache; a filter-CSS escape that reopened a string; the web-readable archive-gate policy (now a file the server does not serve); open-graph image values printed raw; a pretty-URL cache keyed on the address spelling rather than the term; and the rate limiter working out its own server from the request rather than the stored site address.
Round 14 (fixed in 6.67.3)
Scored 35 out of 100.
- SECURITY-001 (Critical). On WordPress 6.2 to 6.9 the price widget’s “add range” control built markup from a field name an author could place in post content, the same class as round 13’s picker issue. Fixed by building the controls as elements.
- SECURITY-002 (Medium). Custom filter CSS could nest a rule inside another rule to escape its scope. Fixed by refusing nested rules.
- SECURITY-003 (Low). Different spellings of the same Fragment state still wrote separate cache files. Fixed by keying on the state the page itself publishes.
- SECURITY-004 (Low). A private taxonomy was still rendered by an author’s filter block and listed by the plugin’s admin term tools. Fixed so the block renders nothing and the tools require the taxonomy to be filterable or the user to manage it.
- SECURITY-005 (Low). Core pingback fetches came from the server itself and so were exempt from the rate limiter. Addressed by the token change below.
Round 15 (fixed in 6.67.5), and the rate-limiter token in 6.67.4
Scored 55 out of 100.
- SECURITY-001 (Critical). The widget term-count query is assembled with placeholder words that are swapped for SQL after the prepared values are in place. A filter value containing one of those words had its own text swapped in, so an anonymous visitor’s search text could run as SQL. Fixed by giving each placeholder a random per-request suffix that no value can contain; the generated query is unchanged.
- SECURITY-002 (Medium). A quote inside an unquoted
url()in custom filter CSS hid an unscoped rule, and a computedpositionvalue slipped past the fixed-position check. Fixed by refusing suchurl()values and allowing only a fixed set ofpositionvalues.
Separately, in 6.67.4 the rate limiter stopped treating any request from a loopback or server address as the site’s own. It now exempts only requests that carry a per-site secret token, which Super Speedy Performance Analysis sends on its profiling requests. This closed the pingback route above and two related address-trust concerns.
Round 16 (fixed in 6.67.6)
Scored 85 out of 100. The 6.67.5 check for unquoted url() values was a pattern match, so the same url( text inside a quoted string could hide a real one after it. Fixed by replacing the pattern match with a scanner that understands CSS strings.
Round 17
Scored 100 out of 100, with no findings. The filter-CSS sanitiser, which earlier rounds had bypassed several times, was attacked again with a large set of crafted inputs covering comments, escapes, at-rules, nesting, selector functions and malformed values; none escaped the filter scope, and a browser confirmed the live page was unaffected.
What the review covered
| Area | Result | Evidence |
|---|---|---|
| Output escaping (PHP) | Pass | Every echo, print and returned-to-output value traced to its source; stored values and request data escaped for their context |
| HTML attribute building | Pass | Widget, block, inline-filter and settings sinks escaped |
| Front-end and admin JavaScript | Pass | DOM, selector, navigation and fetch sinks build from data without injection; same-origin checks on requests and navigation |
| Request entry points | Pass | Front-end hooks, admin-ajax, REST, abilities, admin-post, shortcodes, block render and the must-use plugins checked for capability and nonce |
| SQL | Pass | Every query prepared or cast; placeholder substitution cannot be reached by request data |
| Redirects and remote requests | Pass | Same-origin and safe-redirect checks; the rate-limiter exemption is token-based |
| Fast Ajax and Fragment caching | Pass | Trim acts only on real listings; cache keyed on validated state; configuration and policy files not web-served |
| Custom filter CSS | Pass | Confined to the filter scope; no page-covering position, script vector, nested rule or escape bypass |
| Rate limiting | Pass | Per-visitor limits; own-server exemption requires a per-site token with no address trust |
| Protected meta and private taxonomies | Pass | Protected meta filterable only when configured; private taxonomies not read from the address or listed by admin tools |
| Capabilities and roles | Pass | REST routes, abilities and admin-ajax actions admit only the intended roles |
| Secrets and data exposure | Pass | No secret in the release; the rate-limiter token is generated per site, not autoloaded and never output |
Not scored
These are recorded for completeness and carry no deduction, because none can make a customer’s site insecure:
- Translation strings printed without escaping. A large number of the plugin’s own interface strings are printed with the plain translation functions. Changing one would require a translation file that only an administrator can install, so it cannot be used against a site. Noted for tidying.
- The widget term search treats
%and_as wildcards. This affects only which terms match a search box entry; it is not an injection and has no security effect. - Elementor’s WordPress-widget element. A small number of widget paths through Elementor were reasoned about but not reproduced, because Elementor was not installed on the test sites.
