| 1 |
<?php |
| 2 |
/** |
| 3 |
* DOM guard injection. |
| 4 |
* |
| 5 |
* The tag-level passes in Blocker only ever see the HTML WordPress sent, so a |
| 6 |
* tracker that a page builder, lazy-loader or tag manager builds in the browser |
| 7 |
* loads no matter what the visitor chose. This prints a small script at the very |
| 8 |
* top of `<head>` that intercepts the assignment of a URL to a `<script>` or |
| 9 |
* `<iframe>` and parks it in the same `data-surecookie-*` shape the PHP blocker |
| 10 |
* produces, so consentManager.js restores it on accept with no extra plumbing. |
| 11 |
* |
| 12 |
* Injected into the buffer rather than hooked onto `wp_head`, because a theme or |
| 13 |
* plugin can print a tag manager into `<head>` before any hook fires; the only |
| 14 |
* reliable "first" is the top of the finished document. |
| 15 |
* |
| 16 |
* The payload is consent-agnostic (patterns only), so it is safe to serve from a |
| 17 |
* full-page cache. The guard reads the consent cookie itself at runtime. |
| 18 |
* |
| 19 |
* @package SureCookie\Inc\Modules\ScriptBlocking |
| 20 |
* @since 1.4.0 |
| 21 |
*/ |
| 22 |
|
| 23 |
namespace SureCookie\Inc\Modules\ScriptBlocking; |
| 24 |
|
| 25 |
use SureCookie\Inc\Functions\Settings; |
| 26 |
use SureCookie\Inc\Modules\Services\Pattern_Kinds; |
| 27 |
use SureCookie\Inc\Traits\GetInstance; |
| 28 |
|
| 29 |
if ( ! defined( 'ABSPATH' ) ) { |
| 30 |
exit; // Exit if accessed directly. |
| 31 |
} |
| 32 |
|
| 33 |
/** |
| 34 |
* Dom_Guard class. |
| 35 |
* |
| 36 |
* @since 1.4.0 |
| 37 |
*/ |
| 38 |
class Dom_Guard { |
| 39 |
use GetInstance; |
| 40 |
|
| 41 |
/** |
| 42 |
* Cached guard source, read once per request. |
| 43 |
* |
| 44 |
* @var string|null |
| 45 |
*/ |
| 46 |
private ?string $source = null; |
| 47 |
|
| 48 |
/** |
| 49 |
* Insert the guard at the top of `<head>`. |
| 50 |
* |
| 51 |
* @since 1.4.0 |
| 52 |
* @param string $buffer Page HTML. |
| 53 |
* @return string |
| 54 |
*/ |
| 55 |
public function inject( string $buffer ): string { |
| 56 |
/** |
| 57 |
* Filter whether the client-side DOM guard runs. |
| 58 |
* |
| 59 |
* The guard wraps the native `src` accessors, so this is the kill switch |
| 60 |
* for a site where that conflicts with another plugin. |
| 61 |
* |
| 62 |
* @since 1.4.0 |
| 63 |
* @param bool $enabled Whether to inject the guard. Default true. |
| 64 |
*/ |
| 65 |
if ( ! apply_filters( 'surecookie_dom_guard_enabled', true ) ) { |
| 66 |
return $buffer; |
| 67 |
} |
| 68 |
|
| 69 |
$patterns = $this->build_patterns(); |
| 70 |
if ( empty( $patterns['s'] ) && empty( $patterns['i'] ) && empty( $patterns['y'] ) ) { |
| 71 |
return $buffer; |
| 72 |
} |
| 73 |
|
| 74 |
$source = $this->source(); |
| 75 |
if ( $source === '' ) { |
| 76 |
return $buffer; |
| 77 |
} |
| 78 |
|
| 79 |
// Match the opening <head> tag with its attributes, so the guard lands |
| 80 |
// immediately inside it and ahead of every other tag on the page. |
| 81 |
if ( preg_match( '/<head\b[^>]*>/i', $buffer, $match, PREG_OFFSET_CAPTURE ) !== 1 ) { |
| 82 |
return $buffer; |
| 83 |
} |
| 84 |
|
| 85 |
// Pooling makes the two maps all but identical, and this tag is inlined |
| 86 |
// into every page, so the wire carries what they share once and leaves |
| 87 |
// `p`/`f` for the entries that genuinely differ per kind. |
| 88 |
$shared = array_filter( |
| 89 |
$patterns['s'], |
| 90 |
static fn( $entry, $pattern ) => ( $patterns['i'][ $pattern ] ?? null ) === $entry, |
| 91 |
ARRAY_FILTER_USE_BOTH |
| 92 |
); |
| 93 |
|
| 94 |
$config = wp_json_encode( |
| 95 |
[ |
| 96 |
'a' => $shared, |
| 97 |
'p' => array_diff_key( $patterns['s'], $shared ), |
| 98 |
'f' => array_diff_key( $patterns['i'], $shared ), |
| 99 |
// The link map is `styles` over the pooled script/iframe set, so |
| 100 |
// only the styles bucket and the tag_scoped exclusions have to |
| 101 |
// ship: the guard rebuilds the rest from a/p/f. Sending the whole |
| 102 |
// link map would put every pattern twice into a payload that |
| 103 |
// rides every page. |
| 104 |
'y' => $patterns['y'], |
| 105 |
't' => array_keys( $patterns['t'] ), |
| 106 |
'e' => Blocking_Surface::skippable_categories(), |
| 107 |
'm' => (string) Settings::get( 'consent_model' ), |
| 108 |
'r' => (int) Settings::get( 'consent_renewed_at' ), |
| 109 |
// Core prefixes plus the host they belong to, so the guard spares |
| 110 |
// core exactly as Blocker::is_core_asset() does. The host is sent, |
| 111 |
// not read from `location`: on a domain-mapped install (WPML |
| 112 |
// domain-per-language) the two would disagree about which is ours. |
| 113 |
'c' => array_values( array_map( static fn( array $b ): string => $b[1], Blocker::core_bases() ) ), |
| 114 |
'h' => (string) ( Blocker::core_bases()[0][0] ?? '' ), |
| 115 |
], |
| 116 |
// HEX_TAG keeps a pattern from closing the script tag; slashes stay |
| 117 |
// unescaped because every pattern is a URL fragment and `\/` would |
| 118 |
// add roughly half a kilobyte to every page for nothing. |
| 119 |
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES |
| 120 |
); |
| 121 |
|
| 122 |
if ( $config === false ) { |
| 123 |
return $buffer; |
| 124 |
} |
| 125 |
|
| 126 |
// data-cfasync stops Cloudflare Rocket Loader from deferring the guard, |
| 127 |
// which would put it behind the scripts it exists to intercept. |
| 128 |
$tag = '<script data-cfasync="false" data-surecookie-guard="1">' |
| 129 |
. 'window.surecookieGuard=' . $config . ';' |
| 130 |
. $source |
| 131 |
. '</script>'; |
| 132 |
|
| 133 |
$offset = $match[0][1] + strlen( $match[0][0] ); |
| 134 |
|
| 135 |
return substr( $buffer, 0, $offset ) . $tag . substr( $buffer, $offset ); |
| 136 |
} |
| 137 |
|
| 138 |
/** |
| 139 |
* Flatten the blocking catalog into `pattern => [ category, service ]`, one |
| 140 |
* map per element type. |
| 141 |
* |
| 142 |
* Pools a service's `scripts` and `iframes` patterns exactly as the tag |
| 143 |
* passes do, so the two layers agree: a pattern names a third-party host, and |
| 144 |
* a vendor shipping both an embed and a JS API is the same connection either |
| 145 |
* way. Own kind wins a collision, so pooling only adds coverage. A producer |
| 146 |
* that meant its arrays literally sets `tag_scoped`. |
| 147 |
* |
| 148 |
* Reads the same `surecookie_known_scripts` view the tag passes block from, |
| 149 |
* and applies the same per-resource decisions on top: a resource the admin |
| 150 |
* excluded is dropped, and a category override wins over the catalog's. Skip |
| 151 |
* either and the guard would contradict the tag passes - blocking something |
| 152 |
* the admin allowed, or holding a resource under a category the visitor has |
| 153 |
* already consented to, which consentManager would then restore and the |
| 154 |
* guard would immediately park again. |
| 155 |
* |
| 156 |
* @since 1.4.0 |
| 157 |
* @return array{s: array<string, array{0: string, 1: string}>, i: array<string, array{0: string, 1: string}>, y: array<string, array{0: string, 1: string}>, t: array<string, bool>} |
| 158 |
*/ |
| 159 |
private function build_patterns(): array { |
| 160 |
$catalog = apply_filters( 'surecookie_known_scripts', [] ); |
| 161 |
|
| 162 |
if ( ! is_array( $catalog ) ) { |
| 163 |
return [ |
| 164 |
's' => [], |
| 165 |
'i' => [], |
| 166 |
'y' => [], |
| 167 |
't' => [], |
| 168 |
]; |
| 169 |
} |
| 170 |
|
| 171 |
return [ |
| 172 |
's' => $this->flatten_for( $catalog, 'script', 'scripts' ), |
| 173 |
'i' => $this->flatten_for( $catalog, 'iframe', 'iframes' ), |
| 174 |
// Scoped as a script: a stylesheet row carries the script kind, so |
| 175 |
// its exclusion and override are keyed that way. Own bucket, so |
| 176 |
// un-pooled - a `<link rel=stylesheet>` IS where a styles pattern |
| 177 |
// is observed. Mirrors Blocker::get_link_patterns(). |
| 178 |
'y' => $this->flatten_patterns( $catalog, 'script', 'styles', false ), |
| 179 |
// A tag_scoped rule means its arrays literally, so it must not reach |
| 180 |
// the link map at all; the guard drops these from the pooled set. |
| 181 |
't' => $this->tag_scoped_patterns( $catalog ), |
| 182 |
]; |
| 183 |
} |
| 184 |
|
| 185 |
/** |
| 186 |
* Build one element type's map, mirroring `Blocker::build_patterns()`: the |
| 187 |
* own bucket wins, the pooled one only fills patterns it did not declare. |
| 188 |
* The two have to agree down to the service name, or the guard parks under a |
| 189 |
* label the server pass never used. |
| 190 |
* |
| 191 |
* @since 1.5.0 |
| 192 |
* @param array<string, mixed> $catalog Known-scripts view. |
| 193 |
* @param string $kind Resource kind Resource_Categories scopes by ('script'|'iframe'). |
| 194 |
* @param string $own Catalog key this element type declares under. |
| 195 |
* @return array<string, array{0: string, 1: string}> |
| 196 |
*/ |
| 197 |
/** |
| 198 |
* Patterns whose producer meant its arrays literally, as a set. |
| 199 |
* |
| 200 |
* The link map pools scripts and iframes cross-kind, and a `tag_scoped` |
| 201 |
* rule opts out of pooling - so an admin rule scoped to "Script" must not |
| 202 |
* gate a `<link>`. The server drops these in `flatten_patterns()`; the |
| 203 |
* guard rebuilds the pooled set from a/p/f and so needs them by name. |
| 204 |
* |
| 205 |
* @since 1.5.0 |
| 206 |
* @param array<string, mixed> $catalog Known-scripts view. |
| 207 |
* @return array<string, bool> |
| 208 |
*/ |
| 209 |
private function tag_scoped_patterns( array $catalog ): array { |
| 210 |
$scoped = []; |
| 211 |
|
| 212 |
foreach ( $catalog as $services ) { |
| 213 |
if ( ! is_array( $services ) ) { |
| 214 |
continue; |
| 215 |
} |
| 216 |
|
| 217 |
foreach ( $services as $service ) { |
| 218 |
if ( ! is_array( $service ) || empty( $service['tag_scoped'] ) ) { |
| 219 |
continue; |
| 220 |
} |
| 221 |
|
| 222 |
foreach ( Pattern_Kinds::buckets() as $bucket ) { |
| 223 |
foreach ( (array) ( $service[ $bucket ] ?? [] ) as $pattern ) { |
| 224 |
$pattern = (string) $pattern; |
| 225 |
if ( $pattern !== '' ) { |
| 226 |
$scoped[ $pattern ] = true; |
| 227 |
} |
| 228 |
} |
| 229 |
} |
| 230 |
} |
| 231 |
} |
| 232 |
|
| 233 |
return $scoped; |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* Build one element type's map: the own bucket wins, the pooled one only |
| 238 |
* fills patterns it did not declare. |
| 239 |
* |
| 240 |
* @since 1.5.0 |
| 241 |
* @param array<string, mixed> $catalog Known-scripts view. |
| 242 |
* @param string $kind Resource kind Resource_Categories scopes by ('script'|'iframe'). |
| 243 |
* @param string $own Catalog key this element type declares under. |
| 244 |
* @return array<string, array{0: string, 1: string}> |
| 245 |
*/ |
| 246 |
private function flatten_for( array $catalog, string $kind, string $own ): array { |
| 247 |
$cross = $own === 'scripts' ? 'iframes' : 'scripts'; |
| 248 |
|
| 249 |
return $this->flatten_patterns( $catalog, $kind, $own, false ) |
| 250 |
+ $this->flatten_patterns( $catalog, $kind, $cross, true ); |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Collect one bucket of the catalog into `pattern => [ category, service ]`, |
| 255 |
* with the admin's per-resource decisions for `$kind` already applied. |
| 256 |
* |
| 257 |
* @since 1.5.0 |
| 258 |
* @param array<string, mixed> $catalog Known-scripts view. |
| 259 |
* @param string $kind Resource kind ('script'|'iframe'). |
| 260 |
* @param string $bucket Catalog key to read ('scripts'|'iframes'). |
| 261 |
* @param bool $cross_kind Whether this is the pooled pass, which a |
| 262 |
* `tag_scoped` producer opts out of. |
| 263 |
* @return array<string, array{0: string, 1: string}> |
| 264 |
*/ |
| 265 |
private function flatten_patterns( array $catalog, string $kind, string $bucket, bool $cross_kind ): array { |
| 266 |
$patterns = []; |
| 267 |
|
| 268 |
foreach ( $catalog as $category => $services ) { |
| 269 |
if ( ! is_array( $services ) ) { |
| 270 |
continue; |
| 271 |
} |
| 272 |
|
| 273 |
foreach ( $services as $service_key => $service ) { |
| 274 |
if ( ! is_array( $service ) || ! is_array( $service[ $bucket ] ?? null ) ) { |
| 275 |
continue; |
| 276 |
} |
| 277 |
|
| 278 |
// `path` narrows a rule to resources that also contain that |
| 279 |
// fragment. The guard matches on a single substring, so a |
| 280 |
// narrowed rule is skipped rather than over-blocking its host. |
| 281 |
if ( ! empty( $service['path'] ) ) { |
| 282 |
continue; |
| 283 |
} |
| 284 |
|
| 285 |
// `tag_scoped` means the producer meant its arrays literally - |
| 286 |
// the admin's script/iframe rule type is what sets it. |
| 287 |
if ( $cross_kind && ! empty( $service['tag_scoped'] ) ) { |
| 288 |
continue; |
| 289 |
} |
| 290 |
|
| 291 |
foreach ( $service[ $bucket ] as $pattern ) { |
| 292 |
$pattern = (string) $pattern; |
| 293 |
if ( $pattern === '' || Resource_Categories::matches_excluded_src( $pattern, $kind ) ) { |
| 294 |
continue; |
| 295 |
} |
| 296 |
|
| 297 |
/** |
| 298 |
* Filter: leave a pattern out of the browser guard's map. |
| 299 |
* |
| 300 |
* The guard exists to catch what the server pass cannot see, |
| 301 |
* so anything the server is going to let through has to be |
| 302 |
* dropped here too or the two layers disagree - which is how |
| 303 |
* an always-allowed resource ended up loading in the page and |
| 304 |
* still being intercepted in the browser. Pro uses this for |
| 305 |
* its whitelist; the free exclusion is handled above. |
| 306 |
* |
| 307 |
* The subject here is the catalog PATTERN, not a URL, so an |
| 308 |
* entry narrower than the pattern cannot drop it: that one |
| 309 |
* resource keeps its browser-side placeholder rather than the |
| 310 |
* whole service being released, which is the safe direction. |
| 311 |
* Closing that gap needs per-URL evaluation in the guard. |
| 312 |
* |
| 313 |
* @since 1.5.0 |
| 314 |
* @param bool $skip Whether to omit this pattern. |
| 315 |
* @param string $pattern Blocking pattern. |
| 316 |
* @param string $kind Resource kind ('script'|'iframe'). |
| 317 |
*/ |
| 318 |
if ( apply_filters( 'surecookie_guard_skip_pattern', false, $pattern, $kind ) ) { |
| 319 |
continue; |
| 320 |
} |
| 321 |
|
| 322 |
// The pattern carries the host the override keys match on. |
| 323 |
$patterns[ $pattern ] = [ |
| 324 |
Resource_Categories::resolve( $pattern, (string) $category, $kind ), |
| 325 |
(string) $service_key, |
| 326 |
]; |
| 327 |
} |
| 328 |
} |
| 329 |
} |
| 330 |
|
| 331 |
return $patterns; |
| 332 |
} |
| 333 |
|
| 334 |
/** |
| 335 |
* The guard's built (minified) source. |
| 336 |
* |
| 337 |
* @since 1.4.0 |
| 338 |
* @return string |
| 339 |
*/ |
| 340 |
private function source(): string { |
| 341 |
if ( $this->source !== null ) { |
| 342 |
return $this->source; |
| 343 |
} |
| 344 |
|
| 345 |
$path = SURECOOKIE_DIR . 'build/dom-guard.js'; |
| 346 |
|
| 347 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a bundled plugin asset, not a remote resource. |
| 348 |
$contents = is_readable( $path ) ? file_get_contents( $path ) : false; |
| 349 |
|
| 350 |
$this->source = is_string( $contents ) ? trim( $contents ) : ''; |
| 351 |
|
| 352 |
return $this->source; |
| 353 |
} |
| 354 |
} |
| 355 |
|