PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / class-server-rules.php

class-server-rules.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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