PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / class-server-rules.php

class-server-rules.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-server-rules.php

365 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server_Rules — translate the user's cache-exclusion settings into rules
4 * the web server itself can enforce.
5 *
6 * Why this exists: our speed win comes from letting nginx / Apache serve a
7 * cached page without ever starting PHP. But that means PHP's exclusion
8 * checks (Cache::should_cache()) never run on a warm page. Before this
9 * class, the server rules hardcoded three cookie names and tested no user
10 * agent at all, so every `excluded_cookies` / `bypass_user_agents` entry
11 * the user typed applied only while a page was cold — the settings screen
12 * said the rule was active, and on a warm page it was not.
13 *
14 * Generating server config from user input is the dangerous part: a bad
15 * rule in .htaccess is a 500 on the whole site, and a bad rule in nginx
16 * config makes `nginx -t` fail, which can take down every vhost on the
17 * box. So the rules here are deliberately conservative:
18 *
19 * - Only plain names and simple `*` wildcards are emitted, fully escaped.
20 * - Raw-regex (`~`) patterns are SKIPPED and counted, never passed
21 * through — we cannot vouch for arbitrary user PCRE inside a server
22 * config, and the two regex dialects differ anyway.
23 * - The three historical cookie names are always merged in as a floor,
24 * so a corrupt or empty setting can never produce rules weaker than
25 * what shipped before.
26 *
27 * PHP remains the authority. A missing, stale, or hand-broken server
28 * config can only ever cost speed, never correctness: anything the server
29 * declines to serve falls through to PHP, which re-applies the full rule
30 * list (including the `~` regex patterns skipped here).
31 *
32 * @package XSpeed
33 */
34
35 declare(strict_types=1);
36
37 namespace XSpeed;
38
39 defined( 'ABSPATH' ) || exit;
40
41 final class Server_Rules {
42
43 /**
44 * Cookie names that must always bypass the server-served fast path,
45 * regardless of settings. These are the three the rules hardcoded
46 * before this class existed; keeping them as a floor means a broken
47 * or empty `excluded_cookies` value can never make caching LESS safe
48 * than it was.
49 *
50 * @var string[]
51 */
52 public const COOKIE_FLOOR = array(
53 'wordpress_logged_in',
54 'comment_author',
55 'wp-postpass_',
56 );
57
58 /**
59 * The conventional "never serve this visitor from cache" cookie.
60 *
61 * No WordPress core code sets it — it exists purely as a slot for
62 * plugins, and it is already in the default bypass rules of SpinupWP,
63 * GridPane, RunCloud and most reference nginx configs. PHP sets it
64 * (see Cache::maybe_set_bypass_cookie()) whenever it decides a visitor
65 * must not be served from cache, so the server can enforce the whole
66 * rule list by testing this ONE name — which means adding a new
67 * excluded cookie needs no config change and no nginx reload.
68 *
69 * Limit, stated plainly because it belongs in the docs too: this only
70 * covers visitors PHP has seen at least once. That is essentially
71 * every real case (a cart cookie is set by an add-to-cart request, a
72 * login cookie by wp-login.php), but it cannot cover user-agent rules
73 * — a bot's very first request to a warm page never reaches PHP. That
74 * is why the UA rules are still emitted into the config.
75 */
76 public const BYPASS_COOKIE = 'wordpress_no_cache';
77
78 /**
79 * Cap on how many patterns we emit into a server config. A pathological
80 * settings value shouldn't produce a multi-kilobyte regex that slows
81 * every request or trips nginx's config limits.
82 */
83 private const MAX_PATTERNS = 100;
84
85 /**
86 * Longest single pattern we'll emit. Anything longer is treated as
87 * unsupported and counted as skipped.
88 */
89 private const MAX_PATTERN_LEN = 120;
90
91 /**
92 * Characters we are willing to put inside an emitted server rule.
93 *
94 * This is a config-syntax guard, not a regex guard. The emitted line is
95 * `if ( $http_cookie ~* "(...)" )` on nginx and a whitespace-delimited
96 * `RewriteCond %{HTTP_COOKIE} !(...) [NC]` on Apache, so a `"` closes
97 * nginx's string early (`nginx -t` fails, taking every vhost on the box
98 * with it) and a bare space adds an argument to RewriteCond (HTTP 500 on
99 * every request — and `.htaccess` is parsed per-request, so `httpd -t`
100 * still reports Syntax OK).
101 *
102 * `preg_quote()` does not help: it escapes for PCRE, not for the config
103 * dialect, and `\"` is still a `"` to nginx's tokenizer.
104 *
105 * Anything outside this set is unrepresentable, so we skip it and let the
106 * caller report it as "enforced by PHP only" — the same degradation `~`
107 * and `[` already get. Cookie names are `token` per RFC 6265 and cannot
108 * legally contain a quote or a space, so no valid cookie exclusion is
109 * lost. User-agent entries legitimately contain spaces; those are handled
110 * by `user_agent_rule()`, which quotes per target syntax rather than
111 * skipping.
112 */
113 private const SAFE_PATTERN_CHARS = '/^[A-Za-z0-9_\-.*?\/]+$/';
114
115 /**
116 * User-agent entries may additionally contain a space, because real UA
117 * strings ("Mozilla/5.0 (compatible; Googlebot")) are full of them and
118 * skipping every one would gut the feature. Spaces are made safe by the
119 * emitters, which quote the UA condition; everything else that could
120 * break a config line is still excluded.
121 *
122 * Parentheses, `+`, `:`, `;` and `,` are included for the same reason —
123 * real UA strings are full of them ("Mozilla/5.0 (compatible;
124 * Googlebot/2.1; +http://…)"). They are inert inside the quoted condition
125 * both emitters produce, and `preg_quote()` escapes them before they
126 * reach the regex, so neither the config parser nor PCRE sees syntax.
127 */
128 private const SAFE_UA_CHARS = '/^[A-Za-z0-9_\-.*?\/ ()+:;,]+$/';
129
130 /**
131 * Build the cookie-name alternation for a server rule.
132 *
133 * @param string[] $patterns Raw `excluded_cookies` setting.
134 * @return array{regex:string,skipped:int} Regex body (no delimiters,
135 * no anchors) plus the count of patterns we could not express.
136 */
137 public static function cookie_rule( array $patterns ): array {
138 $built = self::build_alternation( $patterns );
139
140 // Merge the floor + the generic bypass cookie in, deduplicated.
141 // These are literal names, so they need escaping exactly like any
142 // other — `wp-postpass_` contains a `-`, harmless in a regex but
143 // escaped anyway so the treatment is uniform and future names
144 // can't surprise us.
145 $floor = array();
146 foreach ( array_merge( self::COOKIE_FLOOR, array( self::BYPASS_COOKIE ) ) as $name ) {
147 $floor[] = preg_quote( $name, '' );
148 }
149
150 $parts = array_values( array_unique( array_merge( $floor, $built['parts'] ) ) );
151
152 return array(
153 'regex' => implode( '|', $parts ),
154 'skipped' => $built['skipped'],
155 );
156 }
157
158 /**
159 * Build the user-agent alternation for a server rule.
160 *
161 * PHP matches user agents with a case-insensitive SUBSTRING test (see
162 * Cache::should_cache()), not a glob — so each entry becomes a plain
163 * escaped literal and the rule is applied case-insensitively by the
164 * caller. An empty list yields an empty regex, and callers must then
165 * omit the rule entirely rather than emit one that matches everything.
166 *
167 * @param string[] $patterns Raw `bypass_user_agents` setting.
168 * @return array{regex:string,skipped:int}
169 */
170 public static function user_agent_rule( array $patterns ): array {
171 $parts = array();
172 $skipped = 0;
173
174 foreach ( $patterns as $pattern ) {
175 $pattern = trim( (string) $pattern );
176 if ( '' === $pattern ) {
177 continue;
178 }
179 // Raw regex is PHP-side only — see the class docblock.
180 if ( '~' === $pattern[0] ) {
181 ++$skipped;
182 continue;
183 }
184 if ( strlen( $pattern ) > self::MAX_PATTERN_LEN ) {
185 ++$skipped;
186 continue;
187 }
188 // Anything that could break out of the emitted config line is
189 // unrepresentable — see SAFE_UA_CHARS.
190 if ( ! preg_match( self::SAFE_UA_CHARS, $pattern ) ) {
191 ++$skipped;
192 continue;
193 }
194 if ( count( $parts ) >= self::MAX_PATTERNS ) {
195 ++$skipped;
196 continue;
197 }
198 // UA matching is substring in PHP, so every character is a
199 // literal here — including `*`, which PHP does NOT treat as a
200 // wildcard on this setting.
201 $parts[] = preg_quote( $pattern, '' );
202 }
203
204 return array(
205 'regex' => implode( '|', array_values( array_unique( $parts ) ) ),
206 'skipped' => $skipped,
207 );
208 }
209
210 /**
211 * Build the URL alternation for a server rule.
212 *
213 * The nginx snippet mirrored the cookie and user-agent exclusions but
214 * not this one, so an excluded URL was only excluded while its page was
215 * cold. That is mostly masked — PHP refuses to write a static file for
216 * an excluded URL, so there is usually nothing for nginx to serve — but
217 * it bites whenever a page was cached BEFORE the rule existed: the file
218 * is already on disk, nginx never consults PHP, and the exclusion is
219 * silently ignored until the next purge. (#169)
220 *
221 * `excluded_urls` uses the same glob/substring dialect as the cookie
222 * list, so build_alternation() does the work — including skipping the
223 * `~raw regex` entries, which are PHP-side only. An empty list yields an
224 * empty regex and the caller must omit the rule entirely rather than
225 * emit one that matches every request.
226 *
227 * @param string[] $patterns Raw `excluded_urls` setting.
228 * @return array{regex:string,skipped:int}
229 */
230 public static function url_rule( array $patterns ): array {
231 $built = self::build_alternation( $patterns );
232
233 return array(
234 'regex' => implode( '|', $built['parts'] ),
235 'skipped' => $built['skipped'],
236 );
237 }
238
239 /**
240 * Translate a list of glob/substring patterns into escaped regex
241 * alternation parts, mirroring Glob_Matcher's semantics as closely as
242 * a server config can.
243 *
244 * Glob_Matcher rules we reproduce:
245 * - a bare substring is "contains" → emitted as an escaped literal
246 * - `*` is any run of characters → emitted as `.*`
247 * - `?` is exactly one character → emitted as `.`
248 * - `\*` is a literal asterisk → escaped literal
249 *
250 * Rules we deliberately do NOT reproduce, counting them as skipped:
251 * - `~raw regex` (dialect mismatch, unvalidated user input)
252 * - `[abc]` character classes (rare here, and the escaping rules
253 * differ between Apache and nginx enough to be risky)
254 *
255 * Note the anchoring difference: Glob_Matcher anchors a pattern once
256 * it contains a glob metacharacter. We do NOT anchor, because these
257 * alternations are matched against the whole Cookie header (which
258 * holds many `name=value` pairs), so an anchored rule would never fire.
259 * Erring toward "matches more" is the safe direction here — the cost
260 * of a false positive is a cache bypass (slower), while a false
261 * negative is serving a page we promised not to (wrong).
262 *
263 * @param string[] $patterns
264 * @return array{parts:string[],skipped:int}
265 */
266 private static function build_alternation( array $patterns ): array {
267 $parts = array();
268 $skipped = 0;
269
270 foreach ( $patterns as $pattern ) {
271 $pattern = trim( (string) $pattern );
272 if ( '' === $pattern ) {
273 continue;
274 }
275 if ( '~' === $pattern[0] ) {
276 ++$skipped;
277 continue;
278 }
279 if ( strpos( $pattern, '[' ) !== false ) {
280 ++$skipped;
281 continue;
282 }
283 // Anything that could break out of the emitted config line is
284 // unrepresentable — see SAFE_PATTERN_CHARS.
285 if ( ! preg_match( self::SAFE_PATTERN_CHARS, $pattern ) ) {
286 ++$skipped;
287 continue;
288 }
289 if ( strlen( $pattern ) > self::MAX_PATTERN_LEN ) {
290 ++$skipped;
291 continue;
292 }
293 if ( count( $parts ) >= self::MAX_PATTERNS ) {
294 ++$skipped;
295 continue;
296 }
297
298 $parts[] = self::glob_to_server_regex( $pattern );
299 }
300
301 return array(
302 'parts' => array_values( array_unique( $parts ) ),
303 'skipped' => $skipped,
304 );
305 }
306
307 /**
308 * Escape a single glob pattern into a regex fragment safe to embed in
309 * both an nginx `~*` test and an Apache RewriteCond.
310 *
311 * Everything is escaped by default; only unescaped `*` and `?` are
312 * promoted to their regex equivalents. This is the function that keeps
313 * a cookie name like `my.cookie[1]` from becoming an active pattern.
314 */
315 private static function glob_to_server_regex( string $glob ): string {
316 $out = '';
317 $len = strlen( $glob );
318 $escape = false;
319
320 for ( $i = 0; $i < $len; $i++ ) {
321 $ch = $glob[ $i ];
322
323 if ( $escape ) {
324 $out .= preg_quote( $ch, '' );
325 $escape = false;
326 continue;
327 }
328 if ( '\\' === $ch ) {
329 $escape = true;
330 continue;
331 }
332 if ( '*' === $ch ) {
333 $out .= '.*';
334 continue;
335 }
336 if ( '?' === $ch ) {
337 $out .= '.';
338 continue;
339 }
340 $out .= preg_quote( $ch, '' );
341 }
342
343 // A trailing lone backslash would leave $escape set; emit nothing
344 // for it rather than an unterminated escape that breaks the regex.
345 return $out;
346 }
347
348 /**
349 * How many patterns across both lists cannot be enforced by the web
350 * server, so the UI can say so plainly instead of implying every rule
351 * is active at the edge.
352 *
353 * @param array $cache_opts The `xspeed_module_cache` settings array.
354 */
355 public static function unsupported_count( array $cache_opts ): int {
356 $cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
357 $uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
358
359 $c = self::cookie_rule( $cookies );
360 $u = self::user_agent_rule( $uas );
361
362 return (int) $c['skipped'] + (int) $u['skipped'];
363 }
364 }
365