PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / modules / script-blocking / entry-match.php

entry-match.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/script-blocking/entry-match.php

241 lines 7.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The one rule for matching a stored exclusion or whitelist entry.
4 *
5 * @package SureCookie
6 * @since 1.5.0
7 */
8
9 namespace SureCookie\Inc\Modules\ScriptBlocking;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Decides whether one stored entry claims one resource.
17 *
18 * Free's "Always load" exclusion and Pro's "Always allowed" whitelist are the
19 * same question asked of two settings, and they used to be two copies of these
20 * rules kept in step by a docblock. They drifted: 694 of 1.6M inputs disagreed.
21 * This class is the single implementation both call, so drift is no longer
22 * possible. It deliberately depends on nothing but PHP and `wp_parse_url()`, so
23 * Pro can load this one file without pulling in the blocking engine.
24 *
25 * Both sides are parsed, never compared as raw strings, because the subject is
26 * a resource URL when the server pass asks and the bare catalog pattern the URL
27 * matched on when the browser-guard builder asks. A substring only ever matched
28 * the longer of the two, so a URL-shaped entry released a resource server-side
29 * and left the guard still wrapping it.
30 *
31 * The entry is classified once, and that decides everything:
32 *
33 * - carries a query or fragment: an exact-URL claim, matched as a substring
34 * - holds no dot: a keyword such as `fbq`, matched as a substring
35 * - its first segment holds no dot: a path fragment such as `assets/app.js`,
36 * matched against the subject. `assets` is not a host
37 * - otherwise a host claim, optionally narrowed by a port and a path
38 *
39 * @since 1.5.0
40 */
41 final class Entry_Match {
42 /**
43 * Whether one stored entry claims one resource.
44 *
45 * @since 1.5.0
46 * @param string $entry Stored exclusion or whitelist value.
47 * @param string $subject Resource URL, or a bare blocking pattern.
48 * @return bool
49 */
50 public static function matches( string $entry, string $subject ): bool {
51 $needle = strtolower( trim( $entry ) );
52 if ( $needle === '' || $subject === '' ) {
53 return false;
54 }
55
56 // A query or fragment is an exact-URL claim, and a value with no dot was
57 // never a host claim: both keep the substring behaviour they were saved
58 // under.
59 if ( strpbrk( $needle, '?#' ) !== false || strpos( $needle, '.' ) === false ) {
60 return stripos( $subject, $needle ) !== false;
61 }
62
63 $claim = self::parts( $needle );
64
65 // No host of its own, so it never was a host claim. Both `/wp-content/x.js`
66 // and the relative `assets/app.min.js` land here: reading `assets` as a
67 // host is what silently killed this shape.
68 if ( $claim['host'] === '' ) {
69 return stripos( $subject, $needle ) !== false;
70 }
71
72 $resource = self::parts( $subject );
73
74 if ( ! self::host_covers( $claim['host'], $resource['host'] ) ) {
75 // A dotted entry may name the file being requested rather than a host,
76 // so `analytics.js` keeps working. Only the basename counts: searching
77 // the whole path released `cdn.tracker.test/google.com/t.js` for a
78 // `google.com` entry, one party freed by another's URL.
79 return $claim['path'] === '' && self::basename_is( $resource['path'], $claim['host'] );
80 }
81
82 // An entry that names a port claims that port alone.
83 if ( $claim['port'] !== '' && $claim['port'] !== $resource['port'] ) {
84 return false;
85 }
86
87 return self::path_covers( $claim['path'], $resource['path'] );
88 }
89
90 /**
91 * A host without its `www.` alias label.
92 *
93 * The one definition; `Blocker::without_www()` forwards here.
94 *
95 * @since 1.5.0
96 * @param string $host Host to normalise.
97 * @return string
98 */
99 public static function without_www( string $host ): string {
100 return strpos( $host, 'www.' ) === 0 ? substr( $host, 4 ) : $host;
101 }
102
103 /**
104 * Host, path and port of an entry or a resource.
105 *
106 * One parser for both sides. Pro used to prepend a scheme for the guard
107 * builder and not for the server pass, which read a leading-slash pattern as
108 * a host and made its two layers disagree.
109 *
110 * @since 1.5.0
111 * @param string $value Entry or subject.
112 * @return array{host: string, path: string, port: string}
113 */
114 private static function parts( string $value ): array {
115 $value = trim( $value );
116
117 if ( strpos( $value, '//' ) === 0 ) {
118 $url = 'https:' . $value; // Protocol-relative.
119 } elseif ( strpos( $value, '://' ) !== false || strpos( $value, '/' ) === 0 ) {
120 $url = $value; // Absolute URL, or root-relative.
121 } elseif ( strpos( self::first_segment( $value ), '.' ) !== false ) {
122 $url = 'https://' . $value; // Bare host, optionally with a path.
123 } else {
124 // A dotless first segment cannot be a host, so this is a relative path.
125 return [
126 'host' => '',
127 'path' => $value,
128 'port' => '',
129 ];
130 }
131
132 $parts = wp_parse_url( $url );
133 $parts = is_array( $parts ) ? $parts : [];
134
135 return [
136 'host' => strtolower( (string) ( $parts['host'] ?? '' ) ),
137 'path' => (string) ( $parts['path'] ?? '' ),
138 'port' => isset( $parts['port'] ) ? (string) $parts['port'] : '',
139 ];
140 }
141
142 /**
143 * Everything before the first slash.
144 *
145 * @since 1.5.0
146 * @param string $value Entry or subject.
147 * @return string
148 */
149 private static function first_segment( string $value ): string {
150 $first = strstr( $value, '/', true );
151 return $first === false ? $value : $first;
152 }
153
154 /**
155 * Whether an entry's host claim covers a resource host.
156 *
157 * @since 1.5.0
158 * @param string $claim Host taken from the stored entry.
159 * @param string $host Host taken from the resource.
160 * @return bool
161 */
162 private static function host_covers( string $claim, string $host ): bool {
163 if ( $host === '' ) {
164 return false;
165 }
166
167 // Exact, or a parent domain, so `google.com` never covers
168 // `evilgoogle.com` or `google.com.attacker.test`.
169 if ( $host === $claim || substr( $host, - ( strlen( $claim ) + 1 ) ) === '.' . $claim ) {
170 return true;
171 }
172
173 // Alias, not subdomain: `www.example.com` and `example.com` are one host,
174 // but `www.example.com` still must not claim `maps.example.com`.
175 return self::without_www( $host ) === self::without_www( $claim );
176 }
177
178 /**
179 * Whether an entry's path claim covers a resource path.
180 *
181 * A path binds on segment boundaries. A raw prefix let `instagram.com/p/`
182 * cover every path starting `/p`, and `google.com/recaptcha` cover
183 * `/recaptcha-evil/`. Compared case-insensitively, because the entry is
184 * lowercased on the way in while a resource path keeps its own casing, as
185 * vendor `/en_US/` paths do.
186 *
187 * A claim spelled without a trailing slash may also stop inside the
188 * filename, which is how the catalog writes `hotjar.com/c/hotjar` for
189 * `/c/hotjar-1234567.js`. That prefix binds to the last segment alone, so
190 * the directory over-matching above stays closed.
191 *
192 * @since 1.5.0
193 * @param string $claim Path taken from the stored entry.
194 * @param string $path Path taken from the resource.
195 * @return bool
196 */
197 private static function path_covers( string $claim, string $path ): bool {
198 $directory = str_ends_with( $claim, '/' );
199 $claim = rtrim( $claim, '/' );
200 if ( $claim === '' ) {
201 return true;
202 }
203
204 $path = rtrim( $path, '/' );
205
206 if ( strcasecmp( $path, $claim ) === 0 || stripos( $path, $claim . '/' ) === 0 ) {
207 return true;
208 }
209
210 $cut = strrpos( $claim, '/' );
211 if ( $directory || $cut === false || $cut !== strrpos( $path, '/' ) ) {
212 return false;
213 }
214
215 $base = substr( $claim, $cut + 1 );
216
217 return $base !== ''
218 && strncasecmp( $claim, $path, $cut ) === 0
219 && strncasecmp( substr( $path, $cut + 1 ), $base, strlen( $base ) ) === 0;
220 }
221
222 /**
223 * Whether a resource path requests exactly this file.
224 *
225 * @since 1.5.0
226 * @param string $path Path taken from the resource.
227 * @param string $claim Filename taken from the stored entry.
228 * @return bool
229 */
230 private static function basename_is( string $path, string $claim ): bool {
231 if ( $path === '' ) {
232 return false;
233 }
234
235 $cut = strrpos( $path, '/' );
236 $base = $cut === false ? $path : substr( $path, $cut + 1 );
237
238 return $base !== '' && strcasecmp( $base, $claim ) === 0;
239 }
240 }
241