PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-cookie-inspector.php

class-cookie-inspector.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.1, at includes/class-cookie-inspector.php

296 lines 9.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cookie_Inspector — detects cache-poisoning Set-Cookie headers on
4 * cacheable pages and names the plugin responsible.
5 *
6 * Why: a single plugin emitting Set-Cookie on every anonymous pageview
7 * silently disables CDN/edge caching for the whole site (Cloudflare
8 * returns BYPASS for any response carrying Set-Cookie). The user sees a
9 * low hit ratio and a slow TTFB with no explanation. Live case: an
10 * analytics session cookie (`ep_session_id`) forced cf-cache-status:
11 * BYPASS on every HTML response of a Cloudflare-fronted site.
12 *
13 * Mechanics: fetch the home page once as an anonymous visitor (no
14 * cookies sent), read the Set-Cookie response headers, and attribute
15 * each cookie to its source plugin via a prefix map. Result is cached
16 * in a transient so Health never pays for the HTTP round-trip on every
17 * paint (same throttling pattern as Cache::probe_static_rewrite).
18 *
19 * @package XSpeed
20 */
21
22 namespace XSpeed;
23
24 defined( 'ABSPATH' ) || exit;
25
26 final class Cookie_Inspector {
27
28 const TRANSIENT = 'xspeed_cookie_probe';
29
30 /**
31 * Known cookie-name prefixes → the plugin that sets them. Checked in
32 * order; first match wins. Extendable via the
33 * `xspeed_cookie_culprits` filter.
34 *
35 * WordPress core cookies are excluded upstream (wordpress_*, wp-*) —
36 * core only sets them on login/comment actions, not anonymous GETs.
37 *
38 * @return array<string,string> prefix => plugin label.
39 */
40 public static function culprit_map(): array {
41 $map = array(
42 'ep_' => 'EmbedPress',
43 'edd_' => 'Easy Digital Downloads',
44 'woocommerce_' => 'WooCommerce',
45 'wp_woocommerce_session' => 'WooCommerce',
46 'tk_ai' => 'Jetpack',
47 'tinvwl_' => 'TI WooCommerce Wishlist',
48 'mailchimp_' => 'Mailchimp',
49 'pys_' => 'PixelYourSite',
50 'mo_' => 'miniOrange',
51 'wfwaf-' => 'Wordfence',
52 'ssupp.' => 'Smartsupp Chat',
53 'PHPSESSID' => 'a PHP session (session_start() on the front end)',
54 );
55 if ( function_exists( 'apply_filters' ) ) {
56 $filtered = apply_filters( 'xspeed_cookie_culprits', $map );
57 if ( is_array( $filtered ) ) {
58 $map = $filtered;
59 }
60 }
61 return $map;
62 }
63
64 /**
65 * Attribute a cookie name to its source plugin. Pure — unit-tested.
66 *
67 * @param string $cookie_name e.g. 'ep_session_id'.
68 * @return string|null Plugin label, or null when unknown.
69 */
70 public static function attribute( string $cookie_name, ?array $map = null ): ?string {
71 $map = null === $map ? self::culprit_map() : $map;
72 foreach ( $map as $prefix => $label ) {
73 if ( 0 === strpos( $cookie_name, (string) $prefix ) ) {
74 return (string) $label;
75 }
76 }
77 return null;
78 }
79
80 /**
81 * Cookie names that never poison edge caches and must not warn:
82 * WordPress core's own (only set on auth/comment actions) and the
83 * consent/test cookies CDNs are configured to ignore.
84 *
85 * @param string $cookie_name Cookie name.
86 */
87 public static function is_ignorable( string $cookie_name ): bool {
88 $ignorable_prefixes = array(
89 'wordpress_',
90 'wp-settings-',
91 'wp_lang',
92 'xspeed_', // our own (e.g. theme cookie) — never persisted on anon GETs.
93 'cookieyes-',
94 'cky-',
95 'moove_gdpr_',
96 // Cloudflare's own edge cookies. Set by the CDN itself, not by
97 // origin PHP, and explicitly ignored by its cache — flagging them
98 // told the user to "fix the plugin setting __cf_bm" when there is
99 // no plugin to fix, on every Cloudflare-fronted site.
100 '__cf_bm',
101 '__cflb',
102 '__cfruid',
103 '__cfwaitingroom',
104 'cf_clearance',
105 );
106 foreach ( $ignorable_prefixes as $prefix ) {
107 if ( 0 === strpos( $cookie_name, $prefix ) ) {
108 return true;
109 }
110 }
111 return 'wordpress_test_cookie' === $cookie_name;
112 }
113
114 /**
115 * Parse Set-Cookie header value(s) into offending cookie names with
116 * attribution. Pure — unit-tested.
117 *
118 * @param string[] $set_cookie_headers One raw Set-Cookie value each.
119 * @return array<int,array{name:string,plugin:?string}>
120 */
121 public static function analyze( array $set_cookie_headers ): array {
122 $out = array();
123 $seen = array();
124 foreach ( self::split_folded( $set_cookie_headers ) as $header ) {
125 $pair = explode( '=', trim( (string) $header ), 2 );
126 $name = trim( $pair[0] );
127 if ( '' === $name || isset( $seen[ $name ] ) || self::is_ignorable( $name ) ) {
128 continue;
129 }
130 $seen[ $name ] = true;
131 $out[] = array(
132 'name' => $name,
133 'plugin' => self::attribute( $name ),
134 );
135 }
136 return $out;
137 }
138
139 /**
140 * Unfold comma-joined Set-Cookie headers into one entry per cookie.
141 *
142 * Some transports (and `wp_remote_retrieve_header` when a response
143 * carries several Set-Cookie lines) hand back a single comma-joined
144 * string. Splitting naively on "," would break `Expires=Wed, 09 Jun
145 * 2027 …`, so we only split on a comma that is followed by a
146 * `name=` pair — the start of the next cookie. Pure — unit-tested.
147 *
148 * @param string[] $headers Raw Set-Cookie values.
149 * @return string[] One cookie per element.
150 */
151 public static function split_folded( array $headers ): array {
152 $out = array();
153 foreach ( $headers as $header ) {
154 $header = trim( (string) $header );
155 if ( '' === $header ) {
156 continue;
157 }
158 // Split on ", " only when what follows looks like `token=`
159 // (a cookie name is a token: no spaces, commas or equals).
160 $parts = preg_split( '/,\s*(?=[A-Za-z0-9!#$%&\'*+\-.^_`|~]+\s*=)/', $header );
161 if ( ! is_array( $parts ) ) {
162 $out[] = $header;
163 continue;
164 }
165 foreach ( $parts as $part ) {
166 $part = trim( $part );
167 if ( '' !== $part ) {
168 $out[] = $part;
169 }
170 }
171 }
172 return $out;
173 }
174
175 /**
176 * Probe the home page as an anonymous visitor and report offending
177 * cookies. Throttled via transient (1 hour); pass $allow_probe=false
178 * to read the cached verdict only (admin bootstrap must never block).
179 *
180 * @return array{checked:bool,cookies:array<int,array{name:string,plugin:?string}>}
181 */
182 /**
183 * Cron hook that refreshes the probe out of band.
184 */
185 const CRON_HOOK = 'xspeed_cookie_probe_refresh';
186
187 /**
188 * True for local/dev hostnames, where self-signed certificates are the
189 * norm and TLS verification would fail the probe outright. Everything
190 * else — i.e. every production site — gets a verified request.
191 *
192 * Pure — unit-tested.
193 *
194 * @param string $url Site URL.
195 */
196 public static function is_local_host( string $url ): bool {
197 // Plain parse_url keeps this pure and testable without a WP bootstrap;
198 // the input is always our own home_url(), never user-supplied.
199 $host = (string) ( parse_url( $url, PHP_URL_HOST ) ?? '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- pure helper, no WP available.
200 if ( '' === $host ) {
201 return false;
202 }
203 $host = strtolower( $host );
204 if ( 'localhost' === $host || '127.0.0.1' === $host || '::1' === $host ) {
205 return true;
206 }
207 foreach ( array( '.local', '.test', '.localhost', '.invalid', '.sb' ) as $suffix ) {
208 if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
209 return true;
210 }
211 }
212 // RFC1918 / link-local literals.
213 return 1 === preg_match( '/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/', $host );
214 }
215
216 /**
217 * Cached verdict, scheduling a background refresh when it's cold.
218 *
219 * Health::checks() runs inside the REST request that paints the
220 * dashboard and inside the MCP get_health tool, so probing inline meant
221 * a user-visible request blocked on a second HTTP round-trip to our own
222 * site — up to the 5s timeout, and worse behind a slow edge or when the
223 * origin is rate-limiting itself. The first paint now reports
224 * `checked:false` (the UI simply omits the row) and the real verdict
225 * lands on the next load.
226 */
227 public static function probe_cached(): array {
228 $cached = get_transient( self::TRANSIENT );
229 if ( is_array( $cached ) ) {
230 return $cached;
231 }
232 self::schedule_refresh();
233 return array(
234 'checked' => false,
235 'cookies' => array(),
236 );
237 }
238
239 /** Queue a one-off background refresh, unless one is already pending. */
240 public static function schedule_refresh(): void {
241 if ( ! function_exists( 'wp_next_scheduled' ) || wp_next_scheduled( self::CRON_HOOK ) ) {
242 return;
243 }
244 wp_schedule_single_event( time() + 30, self::CRON_HOOK );
245 }
246
247 public static function probe( bool $allow_probe = false ): array {
248 $cached = get_transient( self::TRANSIENT );
249 if ( is_array( $cached ) ) {
250 return $cached;
251 }
252 if ( ! $allow_probe || ! function_exists( 'wp_remote_get' ) ) {
253 return array(
254 'checked' => false,
255 'cookies' => array(),
256 );
257 }
258
259 $res = wp_remote_get(
260 home_url( '/' ),
261 array(
262 'timeout' => 5,
263 'headers' => array( 'User-Agent' => 'xSpeed Health Probe/1.0' ),
264 'cookies' => array(), // anonymous — a logged-in probe would false-positive on auth cookies.
265 // Verify TLS in production; relax only for local/dev hosts,
266 // which routinely use self-signed certs. Blanket-disabling
267 // it weakened a real request on every live site.
268 'sslverify' => ! self::is_local_host( home_url( '/' ) ),
269 )
270 );
271
272 if ( is_wp_error( $res ) ) {
273 $result = array(
274 'checked' => false,
275 'cookies' => array(),
276 );
277 set_transient( self::TRANSIENT, $result, 5 * MINUTE_IN_SECONDS );
278 return $result;
279 }
280
281 $raw = wp_remote_retrieve_header( $res, 'set-cookie' );
282 if ( is_string( $raw ) ) {
283 $raw = '' === $raw ? array() : array( $raw );
284 } elseif ( ! is_array( $raw ) ) {
285 $raw = array();
286 }
287
288 $result = array(
289 'checked' => true,
290 'cookies' => self::analyze( $raw ),
291 );
292 set_transient( self::TRANSIENT, $result, HOUR_IN_SECONDS );
293 return $result;
294 }
295 }
296