| @@ -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 | /** |