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

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

336 lines 12.0 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 * Translate a list of glob/substring patterns into escaped regex
212 * alternation parts, mirroring Glob_Matcher's semantics as closely as
213 * a server config can.
214 *
215 * Glob_Matcher rules we reproduce:
216 * - a bare substring is "contains" → emitted as an escaped literal
217 * - `*` is any run of characters → emitted as `.*`
218 * - `?` is exactly one character → emitted as `.`
219 * - `\*` is a literal asterisk → escaped literal
220 *
221 * Rules we deliberately do NOT reproduce, counting them as skipped:
222 * - `~raw regex` (dialect mismatch, unvalidated user input)
223 * - `[abc]` character classes (rare here, and the escaping rules
224 * differ between Apache and nginx enough to be risky)
225 *
226 * Note the anchoring difference: Glob_Matcher anchors a pattern once
227 * it contains a glob metacharacter. We do NOT anchor, because these
228 * alternations are matched against the whole Cookie header (which
229 * holds many `name=value` pairs), so an anchored rule would never fire.
230 * Erring toward "matches more" is the safe direction here — the cost
231 * of a false positive is a cache bypass (slower), while a false
232 * negative is serving a page we promised not to (wrong).
233 *
234 * @param string[] $patterns
235 * @return array{parts:string[],skipped:int}
236 */
237 private static function build_alternation( array $patterns ): array {
238 $parts = array();
239 $skipped = 0;
240
241 foreach ( $patterns as $pattern ) {
242 $pattern = trim( (string) $pattern );
243 if ( '' === $pattern ) {
244 continue;
245 }
246 if ( '~' === $pattern[0] ) {
247 ++$skipped;
248 continue;
249 }
250 if ( strpos( $pattern, '[' ) !== false ) {
251 ++$skipped;
252 continue;
253 }
254 // Anything that could break out of the emitted config line is
255 // unrepresentable — see SAFE_PATTERN_CHARS.
256 if ( ! preg_match( self::SAFE_PATTERN_CHARS, $pattern ) ) {
257 ++$skipped;
258 continue;
259 }
260 if ( strlen( $pattern ) > self::MAX_PATTERN_LEN ) {
261 ++$skipped;
262 continue;
263 }
264 if ( count( $parts ) >= self::MAX_PATTERNS ) {
265 ++$skipped;
266 continue;
267 }
268
269 $parts[] = self::glob_to_server_regex( $pattern );
270 }
271
272 return array(
273 'parts' => array_values( array_unique( $parts ) ),
274 'skipped' => $skipped,
275 );
276 }
277
278 /**
279 * Escape a single glob pattern into a regex fragment safe to embed in
280 * both an nginx `~*` test and an Apache RewriteCond.
281 *
282 * Everything is escaped by default; only unescaped `*` and `?` are
283 * promoted to their regex equivalents. This is the function that keeps
284 * a cookie name like `my.cookie[1]` from becoming an active pattern.
285 */
286 private static function glob_to_server_regex( string $glob ): string {
287 $out = '';
288 $len = strlen( $glob );
289 $escape = false;
290
291 for ( $i = 0; $i < $len; $i++ ) {
292 $ch = $glob[ $i ];
293
294 if ( $escape ) {
295 $out .= preg_quote( $ch, '' );
296 $escape = false;
297 continue;
298 }
299 if ( '\\' === $ch ) {
300 $escape = true;
301 continue;
302 }
303 if ( '*' === $ch ) {
304 $out .= '.*';
305 continue;
306 }
307 if ( '?' === $ch ) {
308 $out .= '.';
309 continue;
310 }
311 $out .= preg_quote( $ch, '' );
312 }
313
314 // A trailing lone backslash would leave $escape set; emit nothing
315 // for it rather than an unterminated escape that breaks the regex.
316 return $out;
317 }
318
319 /**
320 * How many patterns across both lists cannot be enforced by the web
321 * server, so the UI can say so plainly instead of implying every rule
322 * is active at the edge.
323 *
324 * @param array $cache_opts The `xspeed_module_cache` settings array.
325 */
326 public static function unsupported_count( array $cache_opts ): int {
327 $cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array();
328 $uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array();
329
330 $c = self::cookie_rule( $cookies );
331 $u = self::user_agent_rule( $uas );
332
333 return (int) $c['skipped'] + (int) $u['skipped'];
334 }
335 }
336