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 +86 -6 1.1.61.3.4 View file →
@@ -22,8 +22,23 @@
22 22 * users paste LiteSpeed / WP Rocket exclusion lists (which are regex)
23 23 * verbatim by adding the `~` marker. Invalid or over-long regex patterns
24 24 * are rejected safely (never match, never fatal, never match-everything).
25 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 + *
26 41 * @package XSpeed
27 42 */
28 43
29 44 declare(strict_types=1);
@@ -65,8 +80,67 @@
65 80 return 1 === preg_match( $regex, $subject );
66 81 }
67 82
68 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 + /**
69 143 * Compile a user pattern to a PCRE delimited with `#`. Cached
70 144 * for the request lifetime.
71 145 */
72 146 public static function compile( string $pattern ): string {
@@ -107,20 +181,26 @@
107 181
108 182 /**
109 183 * Validate + delimit a user-supplied raw regex (the part after `~`).
110 184 * Returns a `#…#` delimited PCRE (unanchored, so it matches like a
111 - * "contains" regex), or the NEVER sentinel when the pattern is empty,
112 - * too long, or not a valid PCRE. We never let a bad pattern through:
113 - * a regex that errors at match time would otherwise emit warnings on
114 - * every cached request.
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.
115 192 */
116 - private static function compile_regex( string $body ): string {
193 + private static function compile_regex( string $body, bool $anchored = false ): string {
117 194 // Cap length to keep compile + match cheap and bound backtracking
118 195 // exposure from pathological user input.
119 196 if ( '' === $body || strlen( $body ) > 200 ) {
120 197 return self::NEVER;
121 198 }
122 - $regex = '#' . str_replace( '#', '\\#', $body ) . '#';
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 . '#';
123 203 // Validate by compiling against an empty subject. preg_match returns
124 204 // false on a malformed pattern; suppress the warning it emits.
125 205 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- intentional: invalid user regex must degrade to never-match, not warn.
126 206 if ( false === @preg_match( $regex, '' ) ) {