PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
1.3.4 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 All 30 releases
← All changes | includes/class-glob-matcher.php +126 -0 1.0.11.3.4 View file →
@@ -16,8 +16,29 @@
16 16 * semantics: `/cart` matches `/cart`, `/cart/items`, `/foo/cart/bar`.
17 17 * Adding ANY of `* ? [` switches the pattern to anchored glob mode:
18 18 * `/cart/*` matches `/cart/items` but NOT `/foo/cart/bar`.
19 19 *
20 + * RAW REGEX: a pattern prefixed with `~` is treated as a raw PCRE
21 + * (unanchored) — `~utm_[a-z0-9_-]+` matches like a real regex. This lets
22 + * users paste LiteSpeed / WP Rocket exclusion lists (which are regex)
23 + * verbatim by adding the `~` marker. Invalid or over-long regex patterns
24 + * are rejected safely (never match, never fatal, never match-everything).
25 + *
26 + * NAME MODE (`matches_name()` / `any_match_name()`) matches an identifier —
27 + * a query parameter name — instead of a URL, and there "contains" is the
28 + * wrong contract: `ref` would swallow `preference`, `product_ref` and
29 + * `referrer`, collapsing genuinely different pages onto one cache key. In
30 + * name mode a bare pattern is an exact, case-insensitive match, and BOTH
31 + * the glob and `~regex` forms are anchored, so `~utm_[a-z0-9_-]+` matches
32 + * `utm_source` but not `my_utm_source`. URL matching keeps the contains
33 + * semantics above — a path exclusion of `/cart` is meant to catch
34 + * `/cart/items`.
35 + *
36 + * Cookie names deliberately do NOT use name mode: the shipped defaults are
37 + * prefixes of hash-suffixed real cookies (`comment_author` must match
38 + * `comment_author_<hash>`), so tightening them would serve shared cached
39 + * pages to commenters and logged-in-adjacent visitors.
40 + *
20 41 * @package XSpeed
21 42 */
22 43
23 44 declare(strict_types=1);
@@ -59,8 +80,67 @@
59 80 return 1 === preg_match( $regex, $subject );
60 81 }
61 82
62 83 /**
84 + * Does any of `$patterns` match the identifier `$name`?
85 + *
86 + * Name mode: whole-string matching for every pattern form. See the class
87 + * docblock for why an identifier must not use contains semantics.
88 + *
89 + * @param string[] $patterns
90 + */
91 + public static function any_match_name( array $patterns, string $name ): bool {
92 + foreach ( $patterns as $p ) {
93 + $p = (string) $p;
94 + if ( '' === $p ) {
95 + continue;
96 + }
97 + if ( self::matches_name( $p, $name ) ) {
98 + return true;
99 + }
100 + }
101 + return false;
102 + }
103 +
104 + /**
105 + * Match one pattern against an identifier, whole-string and
106 + * case-insensitively.
107 + */
108 + public static function matches_name( string $pattern, string $name ): bool {
109 + return 1 === preg_match( self::compile_name( $pattern ), $name );
110 + }
111 +
112 + /**
113 + * Compile a pattern for name mode. Cached separately from URL mode —
114 + * the same pattern compiles to a different regex in each — under a key
115 + * no user pattern can produce, since a NUL byte cannot survive the
116 + * settings sanitizer.
117 + */
118 + public static function compile_name( string $pattern ): string {
119 + $cache_key = "\0name:" . $pattern;
120 + if ( isset( self::$compiled[ $cache_key ] ) ) {
121 + return self::$compiled[ $cache_key ];
122 + }
123 +
124 + if ( '' !== $pattern && '~' === $pattern[0] ) {
125 + // Anchored, unlike URL mode: an unanchored `~utm_[a-z0-9_-]+`
126 + // still contains-matches `my_utm_source`, which is the very
127 + // over-match name mode exists to stop.
128 + $regex = self::compile_regex( substr( $pattern, 1 ), true );
129 + } elseif ( preg_match( '/(?<!\\\\)[*?\[]/', $pattern ) ) {
130 + $regex = '#^' . self::glob_to_regex( $pattern ) . '$#i';
131 + } else {
132 + // Bare pattern → exact name. Resolve `\X` → `X` first so an
133 + // escaped metacharacter matches itself.
134 + $resolved = preg_replace( '/\\\\(.)/', '$1', $pattern );
135 + $regex = '#^' . preg_quote( (string) $resolved, '#' ) . '$#i';
136 + }
137 +
138 + self::$compiled[ $cache_key ] = $regex;
139 + return $regex;
140 + }
141 +
142 + /**
63 143 * Compile a user pattern to a PCRE delimited with `#`. Cached
64 144 * for the request lifetime.
65 145 */
66 146 public static function compile( string $pattern ): string {
@@ -66,8 +146,17 @@
66 146 public static function compile( string $pattern ): string {
67 147 if ( isset( self::$compiled[ $pattern ] ) ) {
68 148 return self::$compiled[ $pattern ];
69 149 }
150 + // Raw-regex mode: a leading `~` marks the rest as a PCRE pattern.
151 + // We validate it once and store either the usable regex or a
152 + // never-matching sentinel, so a malformed user pattern degrades to
153 + // "matches nothing" instead of fataling or matching everything.
154 + if ( '' !== $pattern && '~' === $pattern[0] ) {
155 + $regex = self::compile_regex( substr( $pattern, 1 ) );
156 + self::$compiled[ $pattern ] = $regex;
157 + return $regex;
158 + }
70 159 // Decide mode based on UNESCAPED glob metacharacters only.
71 160 // `\*` alone keeps the pattern in substring mode (with escapes
72 161 // resolved); `/cart/*` flips to anchored glob mode.
73 162 $has_unescaped_glob = (bool) preg_match( '/(?<!\\\\)[*?\[]/', $pattern );
@@ -79,8 +168,45 @@
79 168 $resolved = preg_replace( '/\\\\(.)/', '$1', $pattern );
80 169 $regex = '#' . preg_quote( (string) $resolved, '#' ) . '#';
81 170 }
82 171 self::$compiled[ $pattern ] = $regex;
172 + return $regex;
173 + }
174 +
175 + /**
176 + * A delimited PCRE that can never match any input — used as the safe
177 + * fallback for invalid / over-long user regex patterns. `(?!)` is the
178 + * empty negative lookahead: it fails at every position.
179 + */
180 + private const NEVER = '#(?!)#';
181 +
182 + /**
183 + * Validate + delimit a user-supplied raw regex (the part after `~`).
184 + * Returns a `#…#` delimited PCRE (unanchored, so it matches like a
185 + * "contains" regex — anchored and case-insensitive in name mode), or the
186 + * NEVER sentinel when the pattern is empty, too long, or not a valid
187 + * PCRE. We never let a bad pattern through: a regex that errors at match
188 + * time would otherwise emit warnings on every cached request.
189 + *
190 + * @param string $body The pattern after the `~` marker.
191 + * @param bool $anchored Wrap in `^(?:…)$` and match case-insensitively.
192 + */
193 + private static function compile_regex( string $body, bool $anchored = false ): string {
194 + // Cap length to keep compile + match cheap and bound backtracking
195 + // exposure from pathological user input.
196 + if ( '' === $body || strlen( $body ) > 200 ) {
197 + return self::NEVER;
198 + }
199 + $escaped = str_replace( '#', '\\#', $body );
200 + // Group before anchoring so a top-level alternation (`a|b`) anchors
201 + // as a whole rather than binding `^` to the first branch only.
202 + $regex = $anchored ? '#^(?:' . $escaped . ')$#i' : '#' . $escaped . '#';
203 + // Validate by compiling against an empty subject. preg_match returns
204 + // false on a malformed pattern; suppress the warning it emits.
205 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- intentional: invalid user regex must degrade to never-match, not warn.
206 + if ( false === @preg_match( $regex, '' ) ) {
207 + return self::NEVER;
208 + }
83 209 return $regex;
84 210 }
85 211
86 212 /**