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 / blocker.php

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

1,929 lines 64.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Script and Content Blocker.
4 *
5 * Modifies page output to block third-party scripts and iframes until consent.
6 *
7 * @package SureCookie\Inc\Modules\ScriptBlocking
8 * @since 0.0.1
9 */
10
11 namespace SureCookie\Inc\Modules\ScriptBlocking;
12
13 use SureCookie\Inc\Functions\Helper;
14 use SureCookie\Inc\Functions\Settings;
15 use SureCookie\Inc\Modules\Services\Pattern_Kinds;
16 use SureCookie\Inc\Traits\GetInstance;
17 use SureCookie\Inc\Traits\PlaceholderContent;
18 use SureCookie\Inc\Utils\Logger;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit; // Exit if accessed directly.
22 }
23
24 /**
25 * Blocker
26 *
27 * Handles output buffering, script modification, and iframe placeholders.
28 *
29 * @since 0.0.1
30 */
31 class Blocker {
32 use GetInstance;
33 use PlaceholderContent;
34
35 /**
36 * Reserved blocking category for newly-detected trackers held by the Pro
37 * Compliance Guard. Resources in this category are blocked until an admin
38 * reviews them and are NEVER released by visitor consent. Mirrored in
39 * `src/utils/consentManager.js` (`isAllowed`) and the Pro Guard
40 * (`Guard::QUARANTINE_CATEGORY`).
41 */
42 public const QUARANTINE_CATEGORY = 'quarantine';
43
44 /**
45 * An attribute value in each of the three HTML spellings, as a branch-reset
46 * group so the value is always capture 1. `src` and `data` were read
47 * quoted-only, so a minifier's `src=https://player.vimeo.com/...` read as no
48 * URL at all and the tag shipped live.
49 *
50 * @since 1.5.0
51 */
52 private const ATTR_VALUE = '(?|"([^"]*)"|\'([^\']*)\'|([^\s>]+))';
53
54 /**
55 * The same three spellings, uncaptured, for strip-the-attribute rewrites.
56 *
57 * @since 1.5.0
58 */
59 private const ATTR_VALUE_ANY = '(?:"[^"]*"|\'[^\']*\'|[^\s>]+)';
60
61 /**
62 * Script `type` values the browser executes. Mirrors the allowlist in
63 * `src/utils/consentManager.js` so blocker output round-trips correctly.
64 */
65 private const EXECUTABLE_SCRIPT_TYPES = [ 'text/javascript', 'module', 'application/javascript', 'application/ecmascript', 'text/ecmascript', 'importmap', 'speculationrules' ];
66
67 /**
68 * Types that list other resources rather than carry tracker code, so a
69 * pattern hit inside one is always collateral. Still in
70 * EXECUTABLE_SCRIPT_TYPES, which consentManager.js validates against.
71 *
72 * @since 1.5.0
73 */
74 private const MANIFEST_SCRIPT_TYPES = [ 'importmap', 'speculationrules' ];
75
76 /**
77 * Marker comment printed at the very start of `wp_footer`, giving the
78 * buffer processor a precise footer boundary for region-constrained rules.
79 * Always stripped from the final output.
80 */
81 private const FOOTER_MARKER = '<!--surecookie:footer-->';
82
83 /**
84 * Largest `data:` script payload worth decoding for pattern matching, in bytes.
85 *
86 * @since 1.5.0
87 */
88 private const MAX_DATA_URI_PAYLOAD = 262144;
89
90 /**
91 * `<link rel>` values that reach the href host before consent, by fetching
92 * it (stylesheet and the preload family) or by opening a connection to it
93 * (preconnect, dns-prefetch) - which is the transfer the Google Fonts
94 * rulings are about, not the file.
95 *
96 * An allowlist, and it has to stay one. `canonical`, `profile` and
97 * `alternate` are on nearly every WordPress page and name a URL the browser
98 * never requests; `icon` and `manifest` do fetch, but a missing favicon
99 * reads as a broken site and no catalog service delivers one.
100 */
101 private const FETCHING_LINK_RELS = [ 'stylesheet', 'preload', 'modulepreload', 'prefetch', 'prerender', 'preconnect', 'dns-prefetch' ];
102
103
104 /**
105 * Memoized core asset bases as [ host, path ]. See core_bases().
106 *
107 * @var array<int, array{0: string, 1: string}>|null
108 */
109 private static ?array $core_bases = null;
110
111 /**
112 * Per-request cache of iframe-pattern lookup map (shared across
113 * block_iframes / block_embeds / block_objects).
114 *
115 * @var array<string, array{name: string, category: string, label: string, path?: string, location?: string}>|null
116 */
117 private ?array $iframe_patterns_cache = null;
118
119 /**
120 * Per-request cache of the link-pattern lookup map.
121 *
122 * @var array<string, array{name: string, category: string, label: string, location: string, path: string, src_only: bool}>|null
123 */
124 private ?array $link_patterns_cache = null;
125
126 /**
127 * Whether the processing buffer has been opened for this request.
128 *
129 * @var bool
130 */
131 private bool $buffer_open = false;
132
133 /**
134 * Memoized should_process() verdict.
135 *
136 * It fires `surecookie_scanner_request_detected`, and the buffer is now
137 * attempted on two hooks, so an unmemoized false verdict double-counted
138 * every scanner request.
139 *
140 * @var bool|null
141 */
142 private ?bool $should_process = null;
143
144 /**
145 * Constructor.
146 *
147 * @since 0.0.1
148 */
149 private function __construct() {
150 $this->register_hooks();
151 }
152
153 /**
154 * Open a full-page output buffer for the resolved template so the finished
155 * HTML can be post-processed, then return the template unchanged so
156 * WordPress renders it normally.
157 *
158 * Untyped by design: this runs at PHP_INT_MAX, so every other callback on
159 * template_include has already returned. Core itself does not trust this
160 * value (see template-loader.php), and returning it untouched keeps a
161 * non-string from another plugin intact.
162 *
163 * @param mixed $template Expected string path to the resolved template.
164 * @since 0.0.1
165 * @since 1.2.2 Buffer the real template output instead of pre-rendering it,
166 * so WordPress 7.0's on-demand block-style hoisting is preserved.
167 * @return mixed The template, untouched.
168 */
169 public function intercept_template( $template = '' ) {
170 if ( ! is_string( $template ) || $template === '' || ! file_exists( $template ) ) {
171 return $template;
172 }
173
174 /*
175 * Open a full-page output buffer and let WordPress include the *real*
176 * template normally, then post-process the finished HTML in the buffer
177 * callback.
178 *
179 * We must NOT render the template ourselves here. Rendering the page
180 * inside this filter (and returning a blank template) runs ahead of
181 * WordPress 7.0's own template-enhancement output buffer, which is
182 * started later at the `wp_before_include_template` action and hoists
183 * on-demand block styles from the body back up into the <head>. Doing
184 * our own early render bypassed that hoisting, leaving classic-theme
185 * block/global styles stranded in the <body> in the wrong cascade order
186 * (e.g. the theme's `body .is-layout-grid{display:grid}` then overrode a
187 * block's responsive `display:flex`, collapsing multi-column layouts).
188 *
189 * By opening our buffer first and returning the real template, WordPress
190 * renders and enhances the page as usual - its buffer nests inside ours -
191 * and our callback processes the already-corrected HTML.
192 */
193 $this->start_buffer();
194
195 return $template;
196 }
197
198 /**
199 * `template_redirect` callback: open the buffer before any plugin that
200 * renders a page from this action can finish and exit.
201 *
202 * @since 1.5.0
203 * @return void
204 */
205 public function open_buffer(): void {
206 $this->start_buffer();
207 }
208
209 /**
210 * Process the output buffer.
211 *
212 * @param string $buffer HTML content.
213 * @param int $phase Bitmask PHP passes to an output handler. The CLEAN bit is
214 * set when the buffer is being discarded rather than sent.
215 * @since 0.0.1
216 * @return string Modified HTML.
217 */
218 public function process_buffer( string $buffer, int $phase = 0 ): string {
219 // PHP sets the CLEAN bit when the buffer is being discarded rather than sent,
220 // which is what a third party's `while ( ob_get_level() ) ob_end_clean()` does
221 // to us. Our return value is thrown away in that case, so the page ships with
222 // no blocking applied and nothing else records it. Core reads the same bit in
223 // wp_finalize_template_enhancement_output_buffer(). See #1082.
224 if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) {
225 Logger::get_instance()->log(
226 'SureCookie: the blocking buffer was discarded by another plugin before it could be sent, so this response shipped unblocked.',
227 'error'
228 );
229
230 return $buffer;
231 }
232
233 // Skip empty buffers.
234 if ( empty( $buffer ) ) {
235 return $buffer;
236 }
237
238 // Check if this is HTML content.
239 if ( ! $this->is_html( $buffer ) ) {
240 return $buffer;
241 }
242
243 // Block scripts and embedded content (iframe/embed/object) if blocking is enabled.
244 if ( Utils::is_blocking_enabled() ) {
245 $buffer = $this->block_scripts( $buffer );
246 $buffer = $this->block_iframes( $buffer );
247 $buffer = $this->block_embeds( $buffer );
248 $buffer = $this->block_objects( $buffer );
249 $buffer = $this->block_links( $buffer );
250
251 /**
252 * Filter the blocked page HTML, for integrations that must gate markup
253 * the tag-level passes above cannot see - a page builder that carries an
254 * embed as widget config and builds the iframe in the browser, say.
255 *
256 * Runs inside `should_process()`, so the scan bypass, geo rules and the
257 * admin/AJAX/REST guards already apply to every callback.
258 *
259 * Callbacks run inside an output-buffer display handler. PHP forbids opening
260 * a buffer there, so an `ob_start()` in a callback raises an uncatchable fatal
261 * that discards the entire response. Keep callbacks to pure string work.
262 *
263 * @since 1.4.0
264 * @param string $buffer Page HTML after the built-in blocking passes.
265 */
266 $buffer = (string) apply_filters( 'surecookie_blocked_buffer', $buffer );
267
268 // Last, so block_scripts() never sees it: the guard carries the whole
269 // pattern catalog inline, which would self-match and neutralize it.
270 // Injection position, not processing order, is what puts it first in
271 // the finished document.
272 $buffer = Dom_Guard::get_instance()->inject( $buffer );
273 }
274
275 // The footer-boundary marker is internal - never ship it.
276 return str_replace( self::FOOTER_MARKER, '', $buffer );
277 }
278
279 /**
280 * Print the footer-boundary marker consumed by split_html_regions().
281 *
282 * @since 1.3.0
283 * @return void
284 */
285 public function mark_footer_start(): void {
286 if ( Utils::is_blocking_enabled() ) {
287 echo self::FOOTER_MARKER; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static HTML comment constant.
288 }
289 }
290
291 /**
292 * Core asset bases as [ host, path ] pairs, resolved once per request.
293 *
294 * Memoized: is_core_asset() runs per tag and nothing here changes mid-request.
295 * Shared with Dom_Guard so both layers agree on which host is ours.
296 *
297 * @since 1.5.0
298 * @return array<int, array{0: string, 1: string}>
299 */
300 public static function core_bases(): array {
301 if ( self::$core_bases !== null ) {
302 return self::$core_bases;
303 }
304
305 $bases = [];
306 foreach ( [ includes_url(), admin_url() ] as $base ) {
307 $parts = wp_parse_url( $base );
308 $parts = is_array( $parts ) ? $parts : [];
309 $path = (string) ( $parts['path'] ?? '' );
310
311 if ( $path === '' ) {
312 continue;
313 }
314
315 $bases[] = [ self::without_www( strtolower( (string) ( $parts['host'] ?? '' ) ) ), $path ];
316 }
317
318 self::$core_bases = $bases;
319
320 return $bases;
321 }
322
323 /**
324 * Drop a leading `www.` so a host comparison is not defeated by the prefix.
325 *
326 * @param string $host Lowercased host.
327 * @since 1.5.0
328 * @return string
329 */
330 public static function without_www( string $host ): string {
331 return Entry_Match::without_www( $host );
332 }
333
334 /**
335 * Open the processing buffer once per request.
336 *
337 * Opening it before WordPress 7.0's own template-enhancement buffer (started
338 * at `wp_before_include_template`) keeps ours on the outside, so block-style
339 * hoisting still runs and we post-process the corrected HTML.
340 *
341 * @since 1.5.0
342 * @return void
343 */
344 private function start_buffer(): void {
345 if ( $this->buffer_open || ! $this->should_process() ) {
346 return;
347 }
348
349 $this->buffer_open = true;
350
351 ob_start( [ $this, 'process_buffer' ] );
352 }
353
354 /**
355 * Register WordPress hooks.
356 *
357 * @since 0.0.1
358 * @return void
359 */
360 private function register_hooks(): void {
361 // A plugin that owns a post type's templates can render the whole page
362 // from template_redirect and exit, so template_include never fires and
363 // the buffer never opens. Blocking is compliance-critical and must not be
364 // cancellable that way, so open as early as a front-end request allows;
365 // template_include stays as the fallback and no-ops if we already did.
366 add_action( 'template_redirect', [ $this, 'open_buffer' ], -PHP_INT_MAX );
367 add_filter( 'template_include', [ $this, 'intercept_template' ], PHP_INT_MAX );
368
369 // Before every other wp_footer callback, so all footer scripts land
370 // after the marker. Stripped again in process_buffer().
371 add_action( 'wp_footer', [ $this, 'mark_footer_start' ], -PHP_INT_MAX );
372 }
373
374 /**
375 * Check if blocking should run.
376 *
377 * @since 0.0.1
378 * @return bool
379 */
380 private function should_process(): bool {
381 if ( $this->should_process === null ) {
382 $this->should_process = $this->evaluate_should_process();
383 }
384
385 return $this->should_process;
386 }
387
388 /**
389 * Decide, once per request, whether blocking runs.
390 *
391 * @since 0.0.1
392 * @return bool
393 */
394 private function evaluate_should_process(): bool {
395 // Check if blocking feature is enabled.
396 if ( ! Utils::is_blocking_enabled() ) {
397 return false;
398 }
399
400 // Skip in admin area.
401 if ( is_admin() ) {
402 return false;
403 }
404
405 // Skip AJAX requests.
406 if ( wp_doing_ajax() ) {
407 return false;
408 }
409
410 // Skip REST API requests.
411 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
412 return false;
413 }
414
415 // Skip feeds.
416 if ( is_feed() ) {
417 return false;
418 }
419
420 if ( $this->is_editor_bypass() ) {
421 return false;
422 }
423
424 // Check content type header.
425 $headers_list = headers_list();
426 foreach ( $headers_list as $header ) {
427 if ( stripos( $header, 'Content-Type:' ) === 0 ) {
428 // Skip JSON responses.
429 if ( stripos( $header, 'application/json' ) !== false ) {
430 return false;
431 }
432 // Skip XML responses.
433 if ( stripos( $header, 'application/xml' ) !== false || stripos( $header, 'text/xml' ) !== false ) {
434 return false;
435 }
436 }
437 }
438
439 // Check geo-location rules - bypass blocking if user is not in a configured region -- This ensures script/content blocking respects the same geo-targeting rules as the consent banner.
440 if ( ! Utils::should_process_based_on_geo() ) {
441 return false;
442 }
443
444 // Allow the SaaS scanner to bypass blocking so it sees the full unblocked page.
445 if ( $this->is_scan_bypass_request() ) {
446 // Record that the scanner's request actually reached WordPress. If a
447 // scan finishes with zero findings and zero reach, the host firewall
448 // blocked the crawl at the edge (see SaasClient::classify_scan_outcome).
449 do_action( 'surecookie_scanner_request_detected' );
450 return false;
451 }
452
453 /**
454 * Filter whether blocking should run.
455 *
456 * Reached from the output-buffer display handler, where PHP forbids opening a
457 * buffer: an `ob_start()` in a callback is an uncatchable fatal that discards
458 * the whole response. Keep callbacks to pure string work.
459 *
460 * @since 0.0.1
461 * @param bool $should_process Whether to process the output.
462 */
463 $filtered = apply_filters( 'surecookie_should_block_scripts', true );
464 return is_bool( $filtered ) ? $filtered : true;
465 }
466
467 /**
468 * Whether blocking is skipped for a frontend builder's own render.
469 *
470 * Frontend builders edit where is_admin() is false, so blocking broke their
471 * tooling (issue #1033). Bailing turns off both the buffer rewrite and the
472 * DOM guard, so the capability alone is not enough: staff browsing normally
473 * are visitors, and a bypass would release even the quarantine category.
474 *
475 * @since 1.5.0
476 * @return bool
477 */
478 private function is_editor_bypass(): bool {
479 if ( ! Helper::is_builder_edit_render() ) {
480 return false;
481 }
482
483 /**
484 * Filter whether logged-in users who can edit content bypass blocking.
485 * Return false to keep consent-blocking scripts for editing staff.
486 *
487 * @since 1.5.0
488 * @param bool $bypass Whether editors bypass blocking. Default true.
489 */
490 if ( ! apply_filters( 'surecookie_bypass_blocking_for_editors', true ) ) {
491 return false;
492 }
493
494 // An unblocked render must never reach visitors from a full-page cache.
495 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
496 define( 'DONOTCACHEPAGE', true );
497 }
498
499 return true;
500 }
501
502
503 /**
504 * Check if buffer is HTML content.
505 *
506 * @param string $buffer Content to check.
507 * @since 0.0.1
508 * @return bool
509 */
510 private function is_html( string $buffer ): bool {
511 // ltrim() does not strip a UTF-8 BOM, and a single byte before the doctype
512 // would otherwise turn blocking off for the entire page, silently.
513 if ( str_starts_with( $buffer, "\xEF\xBB\xBF" ) ) {
514 $buffer = substr( $buffer, 3 );
515 }
516
517 $trimmed = ltrim( $buffer );
518
519 // Check if starts with HTML-like content.
520 if ( empty( $trimmed ) ) {
521 return false;
522 }
523
524 // Skip JSON (starts with { or [).
525 if ( $trimmed[0] === '{' || $trimmed[0] === '[' ) {
526 return false;
527 }
528
529 // Skip XML. The buffer now opens at template_redirect, which is where
530 // core renders wp-sitemap.xsl, and that document carries a <head> the
531 // guard would inject a raw <script> into - leaving it non-well-formed.
532 if ( stripos( $trimmed, '<?xml' ) === 0 ) {
533 return false;
534 }
535
536 // Should start with < for HTML/XML.
537 if ( $trimmed[0] !== '<' ) {
538 return false;
539 }
540
541 // Check for HTML doctype or html tag.
542 if ( preg_match( '/^<!DOCTYPE\s+html|^<html/i', $trimmed ) ) {
543 return true;
544 }
545
546 // Check for common HTML tags.
547 if ( preg_match( '/^<(head|body|div|script|style|meta|link)/i', $trimmed ) ) {
548 return true;
549 }
550
551 return true;
552 }
553
554 /**
555 * Find and block matching scripts in HTML.
556 *
557 * @param string $html HTML content.
558 * @since 0.0.1
559 * @return string Modified HTML.
560 */
561 private function block_scripts( string $html ): string {
562 $all_scripts = apply_filters( 'surecookie_known_scripts', [] );
563
564 if ( empty( $all_scripts ) ) {
565 return $html;
566 }
567
568 // Build patterns for matching.
569 $patterns = $this->build_patterns( $all_scripts, 'scripts' );
570
571 if ( empty( $patterns ) ) {
572 return $html;
573 }
574
575 // Region-constrained rules (head|body|footer) need per-segment passes;
576 // the common case (no constraints) keeps the single whole-page pass.
577 $has_constraint = false;
578 foreach ( $patterns as $info ) {
579 if ( $info['location'] !== 'any' ) {
580 $has_constraint = true;
581 break;
582 }
583 }
584
585 if ( ! $has_constraint ) {
586 return $this->rewrite_script_tags( $html, $patterns );
587 }
588
589 $out = '';
590 foreach ( $this->split_html_regions( $html ) as $segment ) {
591 $region_patterns = array_filter(
592 $patterns,
593 static function ( $info ) use ( $segment ) {
594 $location = $info['location'];
595 return $location === 'any' || in_array( $location, $segment['accepts'], true );
596 }
597 );
598
599 $out .= empty( $region_patterns )
600 ? $segment['html']
601 : $this->rewrite_script_tags( $segment['html'], $region_patterns );
602 }
603
604 return $out;
605 }
606
607 /**
608 * Result of one rewrite pass, or the original HTML when PCRE gave up.
609 *
610 * PCRE returns null when it exceeds pcre.backtrack_limit, which on a large
611 * page is indistinguishable from "nothing matched" - the page then ships
612 * with that pass silently disabled. Say so loudly instead: save_log()
613 * reaches production, where the failure actually happens.
614 *
615 * @since 1.5.0
616 * @param string|null $result Callback result.
617 * @param string $html Original HTML.
618 * @param string $kind Pass name, for the log line.
619 * @return string
620 */
621 private static function settled( ?string $result, string $html, string $kind ): string {
622 if ( $result !== null ) {
623 return $result;
624 }
625
626 Logger::get_instance()->save_log(
627 sprintf(
628 'SureCookie: %s blocking did not run on %s - the page (%d KB) exceeded PHP\'s pcre.backtrack_limit, so its tags were left untouched. Raise pcre.backtrack_limit or reduce the page size.',
629 $kind,
630 esc_url_raw( home_url( add_query_arg( [] ) ) ),
631 (int) ( strlen( $html ) / 1024 )
632 )
633 );
634
635 return $html;
636 }
637
638 /**
639 * Run the script-tag rewrite pass over one HTML chunk.
640 *
641 * @param string $html HTML content.
642 * @param array<string, mixed> $patterns Pattern lookup map.
643 * @since 1.3.0
644 * @return string Modified HTML.
645 */
646 private function rewrite_script_tags( string $html, array $patterns ): string {
647 $result = preg_replace_callback(
648 '/<script\b([^>]*+)>((?:[^<]++|<(?!\/script>))*+)<\/script>/is',
649 function ( $matches ) use ( $patterns ) {
650 return $this->process_script_tag( $matches, $patterns );
651 },
652 $html
653 );
654
655 return self::settled( $result, $html, 'script' );
656 }
657
658 /**
659 * Split the page into head / body / footer segments for region-constrained
660 * matching. Head ends at `</head>`; footer starts at the marker printed by
661 * `mark_footer_start()`. When a boundary is missing, its region folds into
662 * the surrounding segment (which then accepts that region's rules too), so
663 * a constrained rule can never silently stop blocking.
664 *
665 * @param string $html HTML content.
666 * @since 1.3.0
667 * @return array<int, array{html: string, accepts: array<int, string>}>
668 */
669 private function split_html_regions( string $html ): array {
670 $head_end = stripos( $html, '</head>' );
671 $footer_start = strpos( $html, self::FOOTER_MARKER );
672
673 $segments = [];
674
675 if ( $head_end !== false ) {
676 $segments[] = [
677 'html' => substr( $html, 0, $head_end ),
678 'accepts' => [ 'head' ],
679 ];
680 $rest_offset = $head_end;
681 } else {
682 $rest_offset = 0;
683 }
684
685 $body_accepts = $head_end === false ? [ 'head', 'body' ] : [ 'body' ];
686
687 if ( $footer_start !== false && $footer_start >= $rest_offset ) {
688 $segments[] = [
689 'html' => substr( $html, $rest_offset, $footer_start - $rest_offset ),
690 'accepts' => $body_accepts,
691 ];
692 $segments[] = [
693 'html' => substr( $html, $footer_start ),
694 'accepts' => [ 'footer' ],
695 ];
696 } else {
697 $segments[] = [
698 'html' => substr( $html, $rest_offset ),
699 'accepts' => array_merge( $body_accepts, [ 'footer' ] ),
700 ];
701 }
702
703 return $segments;
704 }
705
706 /**
707 * Get the iframe-pattern lookup map, computed once per request.
708 *
709 * Shared by block_iframes / block_embeds / block_objects so the known-scripts
710 * payload is iterated only once per HTTP response. Cache is kept on the
711 * Blocker singleton, which is instantiated fresh per PHP request.
712 *
713 * @since 0.0.1-beta.2
714 * @return array<string, array{name: string, category: string, label: string, path?: string, location?: string}>
715 */
716 private function get_iframe_patterns(): array {
717 if ( $this->iframe_patterns_cache !== null ) {
718 return $this->iframe_patterns_cache;
719 }
720
721 $all_scripts = apply_filters( 'surecookie_known_scripts', [] );
722
723 if ( empty( $all_scripts ) ) {
724 $this->iframe_patterns_cache = [];
725 return $this->iframe_patterns_cache;
726 }
727
728 $this->iframe_patterns_cache = $this->build_patterns( $all_scripts, 'iframes' );
729
730 return $this->iframe_patterns_cache;
731 }
732
733 /**
734 * Find and block matching iframes in HTML.
735 *
736 * @param string $html HTML content.
737 * @since 0.0.1
738 * @return string Modified HTML.
739 */
740 private function block_iframes( string $html ): string {
741 $patterns = $this->get_iframe_patterns();
742
743 if ( empty( $patterns ) ) {
744 return $html;
745 }
746
747 // The attribute run is lazy and the self-closed alternative comes first: a
748 // possessive run swallows the `/` of `/>` and cannot give it back, so
749 // `<iframe ... />` matched nothing while the paired form parked. HTML5 has no
750 // self-closing iframe, so the browser builds the frame and fetches either way.
751 $result = preg_replace_callback(
752 '/<iframe\b([^>]*?)(?:\s*\/>|>((?:[^<]++|<(?!\/iframe>))*+)<\/iframe>)/is',
753 function ( $matches ) use ( $patterns ) {
754 return $this->process_iframe_tag( $matches, $patterns );
755 },
756 $html
757 );
758
759 return self::settled( $result, $html, 'iframe' );
760 }
761
762 /**
763 * Find and block matching <embed> tags in HTML.
764 *
765 * Reuses the `iframes[]` pattern array from blocking-scripts.json since
766 * <iframe>, <embed>, and <object> share URL semantics.
767 *
768 * @param string $html HTML content.
769 * @since 0.0.1-beta.2
770 * @return string Modified HTML.
771 */
772 private function block_embeds( string $html ): string {
773 $patterns = $this->get_iframe_patterns();
774
775 if ( empty( $patterns ) ) {
776 return $html;
777 }
778
779 // <embed> is always self-closing in practice.
780 $result = preg_replace_callback(
781 '/<embed\b([^>]*)\/?>/is',
782 function ( $matches ) use ( $patterns ) {
783 return $this->process_embed_tag( $matches, $patterns );
784 },
785 $html
786 );
787
788 return self::settled( $result, $html, 'embed' );
789 }
790
791 /**
792 * Find and block matching <object> tags in HTML.
793 *
794 * Handles both `<object data="…">…</object>`, legacy
795 * `<object><param name="movie|src|url" value="…"></object>` (Flash-style),
796 * AND self-closed XHTML-style `<object … />`.
797 *
798 * @param string $html HTML content.
799 * @since 0.0.1-beta.2
800 * @return string Modified HTML.
801 */
802 private function block_objects( string $html ): string {
803 $patterns = $this->get_iframe_patterns();
804
805 if ( empty( $patterns ) ) {
806 return $html;
807 }
808
809 // Covers both self-closed `<object … />` and full `<object …>…</object>` forms,
810 // mirroring how block_iframes() handles its two shapes.
811 $result = preg_replace_callback(
812 '/<object\b([^>]*?)(?:\s*\/>|>((?:[^<]++|<(?!\/object>))*+)<\/object>)/is',
813 function ( $matches ) use ( $patterns ) {
814 return $this->process_object_tag( $matches, $patterns );
815 },
816 $html
817 );
818
819 return self::settled( $result, $html, 'object' );
820 }
821
822 /**
823 * Get the link-pattern lookup map, computed once per request.
824 *
825 * `styles` is the bucket a `<link>` OWNS - a stylesheet host is observed on
826 * exactly this tag - so it is collected first and un-pooled. Every other
827 * enforced bucket is pooled on top as cross-kind, because `rel=preload`,
828 * `prefetch` and `preconnect` are not deliveries: they warm a resource some
829 * other tag will request, and a hint that does not follow its target still
830 * fetches the blocked tracker and still leaks the visitor's IP.
831 *
832 * Going through `flatten_patterns()` rather than pooling separately is what
833 * keeps `tag_scoped` and `src_only` honoured here: an admin rule scoped to
834 * "Script" must not gate a `<link>`, and a pooled pattern is evidence about
835 * a host, never about inline code.
836 *
837 * @since 1.5.0
838 * @return array<string, array{name: string, category: string, label: string, location: string, path: string, src_only: bool}>
839 */
840 private function get_link_patterns(): array {
841 if ( $this->link_patterns_cache !== null ) {
842 return $this->link_patterns_cache;
843 }
844
845 $all_scripts = apply_filters( 'surecookie_known_scripts', [] );
846
847 if ( ! is_array( $all_scripts ) || empty( $all_scripts ) ) {
848 $this->link_patterns_cache = [];
849 return $this->link_patterns_cache;
850 }
851
852 $own = 'styles';
853 $patterns = $this->flatten_patterns( $all_scripts, $own, false );
854
855 foreach ( array_keys( Pattern_Kinds::enforced() ) as $bucket ) {
856 if ( $bucket !== $own ) {
857 $patterns += $this->flatten_patterns( $all_scripts, $bucket, true );
858 }
859 }
860
861 $this->link_patterns_cache = $patterns;
862
863 return $this->link_patterns_cache;
864 }
865
866 /**
867 * Find and block matching `<link>` tags in HTML.
868 *
869 * @param string $html HTML content.
870 * @since 1.5.0
871 * @return string Modified HTML.
872 */
873 private function block_links( string $html ): string {
874 $patterns = $this->get_link_patterns();
875
876 if ( empty( $patterns ) ) {
877 return $html;
878 }
879
880 // `<link>` is a void element; the optional self-closing slash sits
881 // inside the attribute run and is preserved with the rest of them.
882 $result = preg_replace_callback(
883 '/<link\b([^>]*)>/i',
884 function ( $matches ) use ( $patterns ) {
885 return $this->process_link_tag( $matches, $patterns );
886 },
887 $html
888 );
889
890 return self::settled( $result, $html, 'link' );
891 }
892
893 /**
894 * Process a single `<link>` tag.
895 *
896 * @param array<int, string> $matches Regex matches.
897 * @param array<string, array{name: string, category: string, label: string, path?: string, location?: string}> $patterns Pattern mappings.
898 * @since 1.5.0
899 * @return string Parked link tag, or the original when it is not blocked.
900 */
901 private function process_link_tag( array $matches, array $patterns ): string {
902 $full_tag = $matches[0];
903 $attributes = $matches[1];
904
905 // Skip if already parked.
906 if ( strpos( $attributes, 'data-surecookie-href' ) !== false ) {
907 return $full_tag;
908 }
909
910 if ( ! $this->link_reaches_host( $attributes ) ) {
911 return $full_tag;
912 }
913
914 $href = $this->link_href( $attributes );
915
916 if ( $href === '' || $this->is_core_asset( $href ) ) {
917 return $full_tag;
918 }
919
920 $match_result = $this->match_pattern( $href, '', $patterns );
921
922 if ( $match_result === null ) {
923 return $full_tag;
924 }
925
926 // Scoped as a script: every scanner files a stylesheet under `scripts`,
927 // so that is the kind the admin's exclusion and override are keyed by.
928 $match_result = $this->gate( 'link', 'script', $href, $match_result );
929
930 if ( $match_result === null ) {
931 return $full_tag;
932 }
933
934 return $this->park_link_tag(
935 $attributes,
936 $href,
937 (string) $match_result['name'],
938 (string) $match_result['category']
939 );
940 }
941
942 /**
943 * Whether a `<link>`'s rel makes the browser reach its href host.
944 *
945 * @param string $attributes Raw attributes string from the tag.
946 * @since 1.5.0
947 * @return bool
948 */
949 private function link_reaches_host( string $attributes ): bool {
950 $rel = $this->link_attribute( $attributes, 'rel' );
951
952 if ( $rel === '' ) {
953 return false;
954 }
955
956 // `rel` is a space-separated token list ("shortcut icon", "preload stylesheet").
957 $rels = preg_split( '/\s+/', strtolower( $rel ) );
958
959 return is_array( $rels ) && array_intersect( $rels, self::FETCHING_LINK_RELS ) !== [];
960 }
961
962 /**
963 * The `<link>`'s href, or '' when it has none.
964 *
965 * @param string $attributes Raw attributes string from the tag.
966 * @since 1.5.0
967 * @return string
968 */
969 private function link_href( string $attributes ): string {
970 return $this->link_attribute( $attributes, 'href' );
971 }
972
973 /**
974 * Read one attribute out of a tag's attribute run.
975 *
976 * Walks name="value" pairs rather than searching for the name, so a value
977 * that happens to contain another attribute's name cannot be mistaken for
978 * it: `href="...?rel=canonical"` used to be read as the tag's own `rel` and
979 * the link was then never parked. Quotes are matched to their own kind, so
980 * an apostrophe inside a double-quoted URL no longer truncates the value.
981 * A name is anchored by a lookbehind rather than by leading whitespace, so a
982 * minifier's `href="a.css"rel="stylesheet"` is still read as two attributes.
983 *
984 * @param string $attributes Raw attributes string from the tag.
985 * @param string $name Attribute to read, lowercase.
986 * @since 1.5.0
987 * @return string Attribute value, trimmed, or '' when absent.
988 */
989 private function link_attribute( string $attributes, string $name ): string {
990 if ( preg_match_all( '/(?<![-\w:])([-\w:]+)\s*=\s*(?|"([^"]*)"|\'([^\']*)\'|([^\s>]+))/', $attributes, $pairs, PREG_SET_ORDER ) === false ) {
991 return '';
992 }
993
994 foreach ( $pairs as $pair ) {
995 // Group 2 is absent for an empty value (`rel=""`), which is no value.
996 if ( strtolower( $pair[1] ) === $name ) {
997 return isset( $pair[2] ) ? trim( $pair[2] ) : '';
998 }
999 }
1000
1001 return '';
1002 }
1003
1004 /**
1005 * Park a `<link>` by moving its href out of the browser's reach.
1006 *
1007 * No placeholder and no `display:none`: a link renders nothing, so removing
1008 * the href is the whole of it. `rel` is deliberately left in place - a link
1009 * with no href is inert either way, and keeping it means the restore only
1010 * has to put one attribute back.
1011 *
1012 * @param string $attributes Original tag attributes string.
1013 * @param string $href Original href.
1014 * @param string $name Matched service key.
1015 * @param string $category Matched service category.
1016 * @since 1.5.0
1017 * @return string Parked tag.
1018 */
1019 private function park_link_tag( string $attributes, string $href, string $name, string $category ): string {
1020 $new_attributes = preg_replace( '/\s*(?<![-\w:])href\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $attributes ) ?? $attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
1021
1022 // esc_url() drops data: URIs, and a stylesheet inlined into one still has
1023 // to survive here or consent could never restore it.
1024 $safe_href = stripos( $href, 'data:' ) === 0 ? esc_attr( $href ) : esc_url( $href );
1025
1026 $tag = '<link data-surecookie-category="' . esc_attr( $this->map_category( $category ) ) . '"';
1027 $tag .= ' data-surecookie-name="' . esc_attr( $name ) . '"';
1028 $tag .= ' data-surecookie-href="' . $safe_href . '"';
1029
1030 $new_attributes = trim( (string) $new_attributes );
1031 if ( $new_attributes !== '' ) {
1032 $tag .= ' ' . $new_attributes;
1033 }
1034
1035 return $tag . '>';
1036 }
1037
1038 /**
1039 * Resolve a matched resource to a blocking decision.
1040 *
1041 * Every pass ends the same way: apply the admin's category override, record
1042 * the match so the resource gets an admin row, offer the per-kind skip
1043 * filter, then let a skippable category through. Five copies of that
1044 * sequence is how one pass ends up honouring an override the others ignore.
1045 *
1046 * `$scope` is the kind the admin's settings are keyed by, which is not
1047 * always `$kind`: embed and object are scoped as iframes, and a link as a
1048 * script. The scope filter fires alongside the kind filter for exactly that
1049 * reason - `surecookie_skip_script` is where the exclusion, the GCM
1050 * whitelist and Pro's whitelist are registered, and a link that ignored
1051 * them would be parked with no admin control able to release it.
1052 *
1053 * @since 1.5.0
1054 * @param string $kind Tag kind, naming the `surecookie_skip_{kind}` filter.
1055 * @param string $scope Kind the admin settings are scoped by ('script'|'iframe').
1056 * @param string $src Resource URL.
1057 * @param array<string, mixed> $match_result Result from match_pattern().
1058 * @return array<string, mixed>|null The match with its final category, or null to let the resource load.
1059 */
1060 private function gate( string $kind, string $scope, string $src, array $match_result ): ?array {
1061 // Honor an admin category override for this resource.
1062 $keys = $this->resolution_keys( $src, $match_result );
1063 $match_result['category'] = Resource_Categories::resolve_first( $keys, (string) $match_result['category'], $scope );
1064
1065 // The catalog is not readable from any admin screen, so note what we
1066 // matched or this resource stays invisible there. $keys[0] is the src for
1067 // a normal match and the pattern for one matched on inline content, which
1068 // has no src of its own to record.
1069 Matched_Resources::get_instance()->record( $scope, (string) ( $keys[0] ?? '' ), (string) $match_result['name'], (string) $match_result['category'] );
1070
1071 $skip = false;
1072
1073 /**
1074 * Filter: allow one resource of this kind to load regardless of consent.
1075 *
1076 * Fires as `surecookie_skip_script`, `_iframe`, `_embed`, `_object` or
1077 * `_link`. Callbacks must accept 5 arguments or the matched pattern is
1078 * lost; see the module CLAUDE.md.
1079 *
1080 * @since 0.0.1-beta.2
1081 * @param bool $skip Whether to skip blocking (default false).
1082 * @param string $src Resource URL.
1083 * @param string $name Matched service key.
1084 * @param string $category Matched service category.
1085 * @param string $pattern Blocking pattern that matched.
1086 */
1087 $skip = apply_filters(
1088 "surecookie_skip_{$kind}",
1089 $skip,
1090 $src,
1091 $match_result['name'],
1092 $match_result['category'],
1093 (string) ( $match_result['matched_pattern'] ?? '' )
1094 );
1095
1096 // A kind whose settings are keyed by another one also consults that
1097 // one's filter, so a host-level allowance registered for scripts still
1098 // releases the link the admin excluded.
1099 if ( ! $skip && $kind !== $scope ) {
1100 /** This filter is documented above. */
1101 $skip = apply_filters(
1102 "surecookie_skip_{$scope}",
1103 $skip,
1104 $src,
1105 $match_result['name'],
1106 $match_result['category'],
1107 (string) ( $match_result['matched_pattern'] ?? '' )
1108 );
1109 }
1110
1111 if ( $skip ) {
1112 return null;
1113 }
1114
1115 return $this->can_category_skip( (string) $match_result['category'] ) ? null : $match_result;
1116 }
1117
1118 /**
1119 * Flatten the catalog into the pattern map one tag pass matches against.
1120 *
1121 * A pattern names a third-party host, not a tag: a vendor shipping both an
1122 * embed and a JS API (Maps, YouTube, Vimeo) is the same connection either
1123 * way, so both arrays feed both passes and a service declared under one is
1124 * gated under the other. Own kind is collected first and wins a collision,
1125 * so pooling only ever adds coverage - it never re-attributes a match.
1126 *
1127 * @param array<string, array<string, mixed>> $all_scripts Known scripts by category.
1128 * @param string $own Catalog key this pass owns ('scripts'|'iframes').
1129 * @since 1.5.0
1130 * @return array<string, array{name: string, category: string, label: string, location: string, path: string, src_only: bool}>
1131 */
1132 private function build_patterns( array $all_scripts, string $own ): array {
1133 $cross = $own === 'scripts' ? 'iframes' : 'scripts';
1134
1135 return $this->flatten_patterns( $all_scripts, $own, false )
1136 + $this->flatten_patterns( $all_scripts, $cross, true );
1137 }
1138
1139 /**
1140 * Collect one bucket of the catalog into a pattern => service map.
1141 *
1142 * @param array<string, array<string, mixed>> $all_scripts Known scripts by category.
1143 * @param string $bucket Catalog key to read ('scripts'|'iframes').
1144 * @param bool $cross_kind Whether this is the pooled pass, which
1145 * a `tag_scoped` producer opts out of.
1146 * @since 1.5.0
1147 * @return array<string, array{name: string, category: string, label: string, location: string, path: string, src_only: bool}>
1148 */
1149 private function flatten_patterns( array $all_scripts, string $bucket, bool $cross_kind ): array {
1150 $patterns = [];
1151
1152 foreach ( $all_scripts as $category => $services ) {
1153 if ( ! is_array( $services ) ) {
1154 continue;
1155 }
1156
1157 foreach ( $services as $service_key => $service ) {
1158 if ( ! is_array( $service ) || empty( $service[ $bucket ] ) || ! is_array( $service[ $bucket ] ) ) {
1159 continue;
1160 }
1161
1162 // `tag_scoped` means the producer meant its arrays literally - the
1163 // admin's script/iframe rule type is the only thing that sets it.
1164 if ( $cross_kind && ! empty( $service['tag_scoped'] ) ) {
1165 continue;
1166 }
1167
1168 foreach ( $service[ $bucket ] as $pattern ) {
1169 $patterns[ $pattern ] = [
1170 'name' => $service_key,
1171 'category' => $category,
1172 'label' => $service['label'] ?? $service_key,
1173 // Optional page-region constraint (head|body|footer).
1174 'location' => $service['location'] ?? 'any',
1175 // Optional narrowing constraint: the resource must
1176 // also contain this path/pattern.
1177 'path' => (string) ( $service['path'] ?? '' ),
1178 // A pooled pattern is evidence about a host seen on
1179 // another tag, never about inline code, so the script
1180 // pass must not match it against script bodies.
1181 'src_only' => $cross_kind,
1182 ];
1183 }
1184 }
1185 }
1186
1187 return $patterns;
1188 }
1189
1190 /**
1191 * Process a single script tag.
1192 *
1193 * @param array<int, string> $matches Regex matches.
1194 * @param array<string, array{name: string, category: string, label: string, path?: string, location?: string}> $patterns Pattern mappings.
1195 * @since 0.0.1
1196 * @return string Modified script tag.
1197 */
1198 private function process_script_tag( array $matches, array $patterns ): string {
1199 $full_tag = $matches[0];
1200 $attributes = $matches[1];
1201 $content = $matches[2] ?? '';
1202
1203 // Skip if already blocked.
1204 if ( strpos( $attributes, 'data-surecookie-category' ) !== false ) {
1205 return $full_tag;
1206 }
1207
1208 // Never block SureCookie's own inline scripts. WordPress emits our
1209 // localized data as id="{handle}-js-{extra,before,after}" (e.g.
1210 // surecookie-public-js-extra, which defines window.surecookiePublicSettings).
1211 // That payload embeds tracker-domain strings - cookie domains and the
1212 // blocking patterns themselves - for client-side use, so matching inline
1213 // CONTENT would self-match a blocking pattern and neutralize the consent
1214 // runtime's own bootstrap. Our handles are always "surecookie"-prefixed;
1215 // no third-party script carries that id.
1216 if ( preg_match( '/\bid\s*=\s*["\']surecookie[-_]/i', $attributes ) ) {
1217 return $full_tag;
1218 }
1219
1220 $type = $this->extract_script_type( $attributes );
1221 if ( $type === 'text/plain' ) {
1222 return $full_tag;
1223 }
1224 if ( $type !== null && ! in_array( $type, self::EXECUTABLE_SCRIPT_TYPES, true ) ) {
1225 return $full_tag;
1226 }
1227
1228 // A manifest is never the tracker, and gating an importmap is unrecoverable:
1229 // the browser ignores one restored after module resolution began, so consent
1230 // cannot repair the page view - only a reload can.
1231 if ( in_array( $type, self::MANIFEST_SCRIPT_TYPES, true ) ) {
1232 return $full_tag;
1233 }
1234
1235 // Extract src attribute.
1236 $src = '';
1237 if ( preg_match( '/src\s*=\s*' . self::ATTR_VALUE . '/i', $attributes, $src_match ) ) {
1238 $src = $src_match[1];
1239 }
1240
1241 // Try to match against known scripts.
1242 $match_result = $this->match_pattern( $src, $content, $patterns );
1243
1244 // A performance plugin may have inlined this script as a data: URI, which
1245 // hides the tracker URL from the matcher. Decode and retry on the real body.
1246 if ( $match_result === null && $src !== '' ) {
1247 $decoded = $this->decode_data_uri_script( $src );
1248 if ( $decoded !== '' ) {
1249 $match_result = $this->match_pattern( '', $decoded, $patterns );
1250 }
1251 }
1252
1253 if ( $match_result === null ) {
1254 return $full_tag;
1255 }
1256
1257 // Only when the resource's OWN url matched: the src regex also lifts
1258 // `data-src` off a lazy tag, and exempting on that would hide an inline
1259 // tracker whose body is the real signal.
1260 if ( ( $match_result['matched_in'] ?? '' ) === 'src' && $this->is_core_asset( $src ) ) {
1261 return $full_tag;
1262 }
1263
1264 $match_result = $this->gate( 'script', 'script', $src, $match_result );
1265
1266 if ( $match_result === null ) {
1267 return $full_tag;
1268 }
1269
1270 // Modify the script tag.
1271 return $this->modify_script_tag(
1272 $attributes,
1273 $content,
1274 $src,
1275 $match_result['name'],
1276 $match_result['category']
1277 );
1278 }
1279
1280 /**
1281 * Process a single iframe tag.
1282 *
1283 * @param array<int, string> $matches Regex matches.
1284 * @param array<string, array{name: string, category: string, label: string, path?: string, location?: string}> $patterns Pattern mappings.
1285 * @since 0.0.1
1286 * @return string Modified iframe tag with placeholder.
1287 */
1288 private function process_iframe_tag( array $matches, array $patterns ): string {
1289 $full_tag = $matches[0];
1290 $attributes = $matches[1];
1291
1292 // Skip if already blocked.
1293 if ( strpos( $attributes, 'data-surecookie-src' ) !== false ) {
1294 return $full_tag;
1295 }
1296
1297 // Extract src attribute.
1298 $src = '';
1299 if ( preg_match( '/src\s*=\s*' . self::ATTR_VALUE . '/i', $attributes, $src_match ) ) {
1300 $src = $src_match[1];
1301 }
1302
1303 if ( empty( $src ) ) {
1304 return $full_tag;
1305 }
1306
1307 // Try to match against known iframe patterns.
1308 $match_result = $this->match_pattern( $src, '', $patterns );
1309
1310 if ( $match_result === null ) {
1311 return $full_tag;
1312 }
1313
1314 $match_result = $this->gate( 'iframe', 'iframe', $src, $match_result );
1315
1316 if ( $match_result === null ) {
1317 return $full_tag;
1318 }
1319
1320 // Build blocked iframe with placeholder.
1321 return $this->build_embedded_placeholder(
1322 'iframe',
1323 $attributes,
1324 $src,
1325 $match_result['name'],
1326 $match_result['category'],
1327 $match_result['label']
1328 );
1329 }
1330
1331 /**
1332 * Process a single <embed> tag.
1333 *
1334 * @param array<int, string> $matches Regex matches.
1335 * @param array<string, array{name: string, category: string, label: string, path?: string, location?: string}> $patterns Pattern mappings.
1336 * @since 0.0.1-beta.2
1337 * @return string Modified embed tag with placeholder, or original tag if not blocked.
1338 */
1339 private function process_embed_tag( array $matches, array $patterns ): string {
1340 $full_tag = $matches[0];
1341 $attributes = $matches[1];
1342
1343 // Skip if already blocked.
1344 if ( strpos( $attributes, 'data-surecookie-src' ) !== false ) {
1345 return $full_tag;
1346 }
1347
1348 // Extract src attribute.
1349 $src = '';
1350 if ( preg_match( '/src\s*=\s*' . self::ATTR_VALUE . '/i', $attributes, $src_match ) ) {
1351 $src = $src_match[1];
1352 }
1353
1354 if ( empty( $src ) ) {
1355 return $full_tag;
1356 }
1357
1358 // Same-origin skip - don't wrap legitimate in-house PDFs/SVGs.
1359 if ( $this->is_same_origin_url( $src ) ) {
1360 return $full_tag;
1361 }
1362
1363 $match_result = $this->match_pattern( $src, '', $patterns );
1364
1365 if ( $match_result === null ) {
1366 return $full_tag;
1367 }
1368
1369 $match_result = $this->gate( 'embed', 'iframe', $src, $match_result );
1370
1371 if ( $match_result === null ) {
1372 return $full_tag;
1373 }
1374
1375 return $this->build_embedded_placeholder(
1376 'embed',
1377 $attributes,
1378 $src,
1379 $match_result['name'],
1380 $match_result['category'],
1381 $match_result['label']
1382 );
1383 }
1384
1385 /**
1386 * Process a single <object> tag.
1387 *
1388 * @param array<int, string> $matches Regex matches.
1389 * @param array<string, array{name: string, category: string, label: string, path?: string, location?: string}> $patterns Pattern mappings.
1390 * @since 0.0.1-beta.2
1391 * @return string Modified object tag with placeholder, or original tag if not blocked.
1392 */
1393 private function process_object_tag( array $matches, array $patterns ): string {
1394 $full_tag = $matches[0];
1395 $attributes = $matches[1];
1396 $inner = $matches[2] ?? '';
1397
1398 // Skip if already blocked.
1399 if ( strpos( $attributes, 'data-surecookie-data' ) !== false ) {
1400 return $full_tag;
1401 }
1402
1403 // Extract data attribute (primary URL source).
1404 $data = '';
1405 if ( preg_match( '/(?<![-\w])data\s*=\s*' . self::ATTR_VALUE . '/i', $attributes, $data_match ) ) {
1406 $data = $data_match[1];
1407 }
1408
1409 // If no data= attribute, look for <param name="movie|src|url" value="..."> child.
1410 if ( empty( $data ) && ! empty( $inner ) ) {
1411 if ( preg_match( '/<param\b[^>]*\bname\s*=\s*["\'](?:movie|src|url)["\'][^>]*\bvalue\s*=\s*["\']([^"\']+)["\']/is', $inner, $param_match ) ) {
1412 $data = $param_match[1];
1413 } elseif ( preg_match( '/<param\b[^>]*\bvalue\s*=\s*["\']([^"\']+)["\'][^>]*\bname\s*=\s*["\'](?:movie|src|url)["\']/is', $inner, $param_match ) ) {
1414 $data = $param_match[1];
1415 }
1416 }
1417
1418 if ( empty( $data ) ) {
1419 return $full_tag;
1420 }
1421
1422 // Same-origin skip.
1423 if ( $this->is_same_origin_url( $data ) ) {
1424 return $full_tag;
1425 }
1426
1427 $match_result = $this->match_pattern( $data, '', $patterns );
1428
1429 if ( $match_result === null ) {
1430 return $full_tag;
1431 }
1432
1433 $match_result = $this->gate( 'object', 'iframe', $data, $match_result );
1434
1435 if ( $match_result === null ) {
1436 return $full_tag;
1437 }
1438
1439 // Neutralize any <param> child that carries the URL so the browser
1440 // doesn't refetch Flash/legacy media while the object is hidden.
1441 $neutralized_inner = $this->neutralize_object_params( $inner );
1442
1443 return $this->build_embedded_placeholder(
1444 'object',
1445 $attributes,
1446 $data,
1447 $match_result['name'],
1448 $match_result['category'],
1449 $match_result['label'],
1450 $neutralized_inner
1451 );
1452 }
1453
1454 /**
1455 * Rename `name` attribute on <param> elements carrying Flash-style URLs so
1456 * the hidden <object> placeholder doesn't still fetch them. Restoration in
1457 * consentManager.js renames the attribute back when consent is given.
1458 *
1459 * @param string $inner Inner HTML of the <object> tag.
1460 * @since 0.0.1-beta.2
1461 * @return string Neutralized inner HTML.
1462 */
1463 private function neutralize_object_params( string $inner ): string {
1464 if ( trim( $inner ) === '' ) {
1465 return $inner;
1466 }
1467
1468 $result = preg_replace_callback(
1469 '/<param\b([^>]*)(\/?>)/is',
1470 static function ( $matches ) {
1471 $attrs = $matches[1];
1472 $closing = $matches[2];
1473
1474 if ( preg_match( '/name\s*=\s*["\'](?:movie|src|url)["\']/i', $attrs ) !== 1 ) {
1475 return $matches[0];
1476 }
1477
1478 // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
1479 $new_attrs = preg_replace(
1480 '/(^|\s)name(\s*=)/i',
1481 '$1data-surecookie-param-name$2',
1482 $attrs
1483 ) ?? $attrs;
1484
1485 return '<param' . $new_attrs . $closing;
1486 },
1487 $inner
1488 );
1489
1490 return $result ?? $inner;
1491 }
1492
1493 /**
1494 * Whether a script URL is a WordPress core asset, which must always load.
1495 *
1496 * Host-gated against the URLs WordPress itself emits, so
1497 * `https://evil.test/wp-includes/track.js` cannot borrow the exemption.
1498 * Deliberately stops at core: the catalog targets self-hosted trackers under
1499 * `/wp-content/` by filename (`matomo.js`), so exempting it would free them.
1500 *
1501 * @param string $src Script `src`; empty for an inline script.
1502 * @since 1.5.0
1503 * @return bool
1504 */
1505 private function is_core_asset( string $src ): bool {
1506 $src = trim( $src );
1507
1508 // Only a root-relative path or http(s) can name a core file. A `data:` URI
1509 // is out: its body parses as the path, so an inlined tracker would borrow this.
1510 if ( strpos( $src, '//' ) === 0 ) {
1511 $src = 'https:' . $src;
1512 } elseif ( $src === '' || ( strpos( $src, '/' ) !== 0 && preg_match( '#^https?://#i', $src ) !== 1 ) ) {
1513 return false;
1514 }
1515
1516 $parts = wp_parse_url( $src );
1517 $parts = is_array( $parts ) ? $parts : [];
1518 $host = self::without_www( strtolower( (string) ( $parts['host'] ?? '' ) ) );
1519 $path = (string) ( $parts['path'] ?? '' );
1520
1521 // A `..` segment means the path does not resolve where it reads. Decoded, or
1522 // `%2e%2e` walks past; per segment, so `foo..min.js` still counts.
1523 if ( $path === '' || in_array( '..', explode( '/', rawurldecode( $path ) ), true ) ) {
1524 return false;
1525 }
1526
1527 // Anchored on the real core URLs, so a subdirectory or multisite install
1528 // follows, and a nested `/wp-content/uploads/wp-includes/` cannot pass.
1529 foreach ( self::core_bases() as [ $core_host, $core_path ] ) {
1530 if ( $host !== '' && $host !== $core_host ) {
1531 continue;
1532 }
1533
1534 if ( stripos( $path, $core_path ) === 0 ) {
1535 return true;
1536 }
1537 }
1538
1539 return false;
1540 }
1541
1542 /**
1543 * Check whether a URL has the same host as the current site.
1544 *
1545 * Used to avoid wrapping first-party PDFs/SVGs embedded via <embed>/<object>.
1546 * Uses exact-host match (no eTLD+1 / Public Suffix List lookup).
1547 *
1548 * @param string $url Resource URL.
1549 * @since 0.0.1-beta.2
1550 * @return bool True if URL is same-origin (exact host), relative, or a data: URI.
1551 */
1552 private function is_same_origin_url( string $url ): bool {
1553 if ( $url === '' ) {
1554 return false;
1555 }
1556
1557 // Non-HTTP schemes and relative URLs - not third-party tracking.
1558 if ( preg_match( '/^(?:about|blob|data|file|javascript|mailto|tel):/i', $url ) === 1 ) {
1559 return true;
1560 }
1561
1562 // Protocol-relative URL - normalize so wp_parse_url resolves host.
1563 if ( strpos( $url, '//' ) === 0 ) {
1564 $url = 'http:' . $url;
1565 }
1566
1567 // Relative URLs (no scheme) are inherently same-origin.
1568 if ( preg_match( '/^https?:/i', $url ) !== 1 ) {
1569 return true;
1570 }
1571
1572 $embed_host = wp_parse_url( $url, PHP_URL_HOST );
1573 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
1574
1575 if ( empty( $embed_host ) || empty( $site_host ) ) {
1576 return false;
1577 }
1578
1579 return strcasecmp( (string) $embed_host, (string) $site_host ) === 0;
1580 }
1581
1582 /**
1583 * Check if a category can be skipped.
1584 *
1585 * @param string $category Category name.
1586 * @since 0.0.1
1587 * @return bool True if category can be skipped, false otherwise.
1588 */
1589 private function can_category_skip( string $category ): bool {
1590 return Blocking_Surface::is_skippable_category( $category );
1591 }
1592
1593 /**
1594 * Decode a script inlined into a `data:` URI back to its JavaScript body.
1595 *
1596 * Performance plugins rewrite inline scripts to `data:text/javascript;base64,...`
1597 * so they can carry `defer`. That buries the tracker URL where substring matching
1598 * cannot see it, so decode the payload and match against that instead.
1599 *
1600 * @param string $src Script src attribute.
1601 * @since 1.5.0
1602 * @return string Decoded JavaScript, or an empty string when $src is not a decodable script data URI.
1603 */
1604 private function decode_data_uri_script( string $src ): string {
1605 if ( stripos( $src, 'data:' ) !== 0 ) {
1606 return '';
1607 }
1608
1609 $comma = strpos( $src, ',' );
1610 if ( $comma === false ) {
1611 return '';
1612 }
1613
1614 $meta = strtolower( substr( $src, 5, $comma - 5 ) );
1615 $payload = substr( $src, $comma + 1 );
1616
1617 // Only script payloads are worth decoding.
1618 if ( strpos( $meta, 'javascript' ) === false && strpos( $meta, 'ecmascript' ) === false ) {
1619 return '';
1620 }
1621
1622 if ( strlen( $payload ) > self::MAX_DATA_URI_PAYLOAD ) {
1623 return '';
1624 }
1625
1626 if ( strpos( $meta, 'base64' ) !== false ) {
1627 $decoded = base64_decode( $payload, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding our own page markup to match tracker patterns.
1628 return is_string( $decoded ) ? $decoded : '';
1629 }
1630
1631 return rawurldecode( $payload );
1632 }
1633
1634 /**
1635 * Match a URL against known patterns.
1636 *
1637 * @param string $src URL to match.
1638 * @param string $content Content to match.
1639 * @param array<string, array{name: string, category: string, label: string, path?: string, location?: string}> $patterns Pattern mappings.
1640 * @since 0.0.1
1641 * @return array{name: string, category: string, label: string, path?: string, location?: string, matched_pattern?: string, matched_in?: string}|null Match result or null.
1642 */
1643 private function match_pattern( string $src, string $content, array $patterns ): ?array {
1644 foreach ( $patterns as $pattern => $info ) {
1645 // A pooled pattern was observed on a different tag, so it says
1646 // nothing about inline code: matching it against a script body
1647 // would park a tag that merely mentions the URL, and everything
1648 // else in it. Only the declaring kind reads $content.
1649 $body = empty( $info['src_only'] ) ? $content : '';
1650 $matched_in = '';
1651 if ( ! empty( $src ) && stripos( $src, $pattern ) !== false ) {
1652 $matched_in = 'src';
1653 } elseif ( ! empty( $body ) && stripos( $body, $pattern ) !== false ) {
1654 $matched_in = 'content';
1655 }
1656
1657 if ( $matched_in === '' ) {
1658 continue;
1659 }
1660
1661 // Optional narrowing constraint: the resource must ALSO contain
1662 // the rule's path/pattern (e.g. host + `/fbevents.js`).
1663 $path = (string) ( $info['path'] ?? '' );
1664 if ( $path !== '' ) {
1665 $path_matched =
1666 ( ! empty( $src ) && stripos( $src, $path ) !== false ) ||
1667 ( ! empty( $body ) && stripos( $body, $path ) !== false );
1668 if ( ! $path_matched ) {
1669 continue;
1670 }
1671 }
1672
1673 // The pattern is the host an admin setting is keyed on. An inline match
1674 // has no src to carry it, so hand it to the caller.
1675 $info['matched_pattern'] = (string) $pattern;
1676 $info['matched_in'] = $matched_in;
1677
1678 return $info;
1679 }
1680
1681 return null;
1682 }
1683
1684 /**
1685 * Candidate keys an admin setting may be keyed on, most specific first.
1686 *
1687 * A resource matched on inline content has no src, and one a performance
1688 * plugin inlined into a `data:` URI has a src no setting can appear in.
1689 *
1690 * @param string $src Resource URL, possibly empty.
1691 * @param array<string,mixed> $match_result Result from match_pattern().
1692 * @since 1.5.0
1693 * @return array<int, string>
1694 */
1695 private function resolution_keys( string $src, array $match_result ): array {
1696 // A content match says nothing about the src, and that src may be a data:
1697 // URI whose decoded body is what matched, so it is not a key here.
1698 if ( ( $match_result['matched_in'] ?? '' ) === 'content' ) {
1699 $src = '';
1700 }
1701
1702 return array_values(
1703 array_filter( [ $src, (string) ( $match_result['matched_pattern'] ?? '' ) ] )
1704 );
1705 }
1706
1707 /**
1708 * Modify a script tag to block it.
1709 *
1710 * @param string $attributes Original attributes string.
1711 * @param string $content Inline script content.
1712 * @param string $src Original src attribute.
1713 * @param string $name Service name.
1714 * @param string $category Category name.
1715 * @since 0.0.1
1716 * @return string Modified script tag.
1717 */
1718 private function modify_script_tag( string $attributes, string $content, string $src, string $name, string $category ): string {
1719 // Map API category names to SureCookie category names.
1720 $mapped_category = $this->map_category( $category );
1721
1722 $type = $this->extract_script_type( $attributes );
1723 $original_type = $type !== null && in_array( $type, self::EXECUTABLE_SCRIPT_TYPES, true ) ? $type : 'none';
1724
1725 // Strip any type attribute (quoted or unquoted HTML5 form).
1726 $new_attributes = preg_replace( '/\s*type\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $attributes ) ?? $attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
1727
1728 // Remove src attribute (will be stored in data attribute).
1729 if ( ! empty( $src ) ) {
1730 $new_attributes = preg_replace( '/\s*src\s*=\s*' . self::ATTR_VALUE_ANY . '/i', '', $new_attributes ) ?? $new_attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
1731 }
1732
1733 // Build new script tag.
1734 $new_tag = '<script type="text/plain"';
1735 $new_tag .= ' data-surecookie-category="' . esc_attr( $mapped_category ) . '"';
1736 $new_tag .= ' data-surecookie-name="' . esc_attr( $name ) . '"';
1737 $new_tag .= ' data-surecookie-original-type="' . esc_attr( $original_type ) . '"';
1738
1739 if ( ! empty( $src ) ) {
1740 // esc_url() drops data: URIs, and a script inlined into one still has to
1741 // survive here or consent could never restore it.
1742 $safe_src = stripos( $src, 'data:' ) === 0 ? esc_attr( $src ) : esc_url( $src );
1743 $new_tag .= ' data-surecookie-src="' . $safe_src . '"';
1744 }
1745
1746 // Add remaining attributes.
1747 $new_attributes = trim( (string) $new_attributes );
1748 if ( ! empty( $new_attributes ) ) {
1749 $new_tag .= ' ' . $new_attributes;
1750 }
1751
1752 $new_tag .= '>' . $content . '</script>';
1753
1754 return $new_tag;
1755 }
1756
1757 /**
1758 * Extract the normalized script `type` from a tag's attribute string.
1759 *
1760 * Handles quoted (`type="module"`, `type='module'`) and unquoted HTML5
1761 * (`type=module`) forms, lowercases, trims whitespace, and strips MIME
1762 * parameters (`text/javascript; charset=utf-8` → `text/javascript`).
1763 *
1764 * @param string $attributes Raw attributes string from the opening tag.
1765 * @since 0.0.1-beta.2
1766 * @return string|null Normalized type, or null when no (or empty) type attribute.
1767 */
1768 private function extract_script_type( string $attributes ): ?string {
1769 if ( ! preg_match( '/type\s*=(?|\s*"([^"]*)"|\s*\'([^\']*)\'|([^\s>]+))/i', $attributes, $match ) ) {
1770 return null;
1771 }
1772 if ( $match[1] === '' ) {
1773 return null;
1774 }
1775 return strtolower( trim( explode( ';', $match[1], 2 )[0] ) );
1776 }
1777
1778 /**
1779 * Build an embedded-content placeholder for a blocked iframe/embed/object tag.
1780 *
1781 * Renders the same overlay UX ("This content is blocked… Accept & Load")
1782 * for all three tag types; only the hidden inner element differs. The URL
1783 * is stored in `data-surecookie-src` for iframe/embed (both use `src=`) and
1784 * `data-surecookie-data` for object (uses `data=` attribute). consentManager.js
1785 * restores the URL when the user consents.
1786 *
1787 * @param string $tag One of 'iframe', 'embed', 'object'.
1788 * @param string $attributes Original tag attributes string.
1789 * @param string $url Original resource URL (src for iframe/embed, data for object).
1790 * @param string $name Matched service key.
1791 * @param string $category Matched category.
1792 * @param string $label Human-readable vendor label.
1793 * @param string $inner For <object>, the neutralized inner HTML. Ignored otherwise.
1794 * @since 0.0.1-beta.2
1795 * @return string Placeholder HTML.
1796 */
1797 private function build_embedded_placeholder(
1798 string $tag,
1799 string $attributes,
1800 string $url,
1801 string $name,
1802 string $category,
1803 string $label,
1804 string $inner = ''
1805 ): string {
1806 $mapped_category = $this->map_category( $category );
1807
1808 // Normalize tag.
1809 $tag = in_array( $tag, [ 'iframe', 'embed', 'object' ], true ) ? $tag : 'iframe';
1810
1811 // Strip the URL attribute from the original attributes. <iframe>/<embed>
1812 // use src=; <object> uses data=.
1813 $url_attr = $tag === 'object' ? 'data' : 'src';
1814 $new_attributes = preg_replace( '/\s*(?<![-\w])' . $url_attr . '\s*=\s*' . self::ATTR_VALUE_ANY . '/i', '', $attributes ) ?? $attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
1815
1816 // Extract any existing `style` attribute from the original tag and strip it
1817 // so we can merge its value with our `display:none;` declaration. Emitting
1818 // two `style` attributes is invalid HTML - the HTML parser keeps only the
1819 // first, which would silently discard our `display:none;` and leave the
1820 // blocked element visible.
1821 $existing_style = '';
1822 if ( preg_match( '/style\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', (string) $new_attributes, $style_match ) === 1 ) {
1823 $existing_style = trim( $style_match[1] !== '' ? $style_match[1] : ( $style_match[2] ?? '' ) );
1824 $new_attributes = preg_replace( '/\s*style\s*=\s*(?:"[^"]*"|\'[^\']*\')/i', '', (string) $new_attributes ) ?? $new_attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
1825 }
1826
1827 // Append `display:none;` so it always wins the cascade for inline styles
1828 // (later declarations override earlier ones for the same property).
1829 $combined_style = $existing_style;
1830 if ( $combined_style !== '' && substr( $combined_style, -1 ) !== ';' ) {
1831 $combined_style .= ';';
1832 }
1833 $combined_style .= 'display:none;';
1834
1835 // Attribute used to stash the URL on the hidden element for later restore.
1836 $data_url_attr = $tag === 'object' ? 'data-surecookie-data' : 'data-surecookie-src';
1837
1838 // Embed dimensions from the tag, used only as a pre-paint fallback size.
1839 $original_width = preg_match( '/(?:^|\s)width\s*=\s*["\']?(\d+)/i', $attributes, $w_match ) === 1 ? (int) $w_match[1] : 0;
1840 $original_height = preg_match( '/(?:^|\s)height\s*=\s*["\']?(\d+)/i', $attributes, $h_match ) === 1 ? (int) $h_match[1] : 0;
1841
1842 // Resolve the optional placeholder image; the overlay renderer resolves the
1843 // admin-editable copy and button label itself.
1844 $image = $this->resolve_placeholder_image( $name, $mapped_category, $url );
1845
1846 // Fallback only; consentManager.matchPlaceholderSizes() then sets the
1847 // exact height from the embed's real (often CSS-driven) rendered box.
1848 if ( $original_height > 0 ) {
1849 $wrapper_style = sprintf( 'width:100%%;height:%dpx;', $original_height );
1850 } else {
1851 $wrapper_style = 'width:100%;min-height:160px;';
1852 }
1853
1854 // Outer placeholder wrapper. Carries the banner's root classes
1855 // (surecookie-styles + surecookie-public-banner-wrapper) so it inherits the
1856 // same box-sizing/font reset the banner uses and is insulated from the
1857 // active theme's styles, exactly like the consent banner.
1858 $wrapper_class = 'surecookie-styles surecookie-public-banner-wrapper surecookie-placeholder surecookie-placeholder-' . $name;
1859 if ( $image !== '' ) {
1860 $wrapper_class .= ' surecookie-placeholder-has-image';
1861 }
1862 $placeholder = '<div class="' . esc_attr( $wrapper_class ) . '"';
1863 $placeholder .= ' data-surecookie-name="' . esc_attr( $name ) . '"';
1864 $placeholder .= ' data-surecookie-category="' . esc_attr( $mapped_category ) . '"';
1865 if ( $original_width > 0 ) {
1866 $placeholder .= ' data-surecookie-width="' . esc_attr( (string) $original_width ) . '"';
1867 }
1868 if ( $original_height > 0 ) {
1869 $placeholder .= ' data-surecookie-height="' . esc_attr( (string) $original_height ) . '"';
1870 }
1871 $placeholder .= ' style="' . esc_attr( $wrapper_style ) . '"';
1872 $placeholder .= '>';
1873
1874 $placeholder .= $this->render_placeholder_overlay( $mapped_category, $label, $image );
1875
1876 // Hidden element (restored on consent).
1877 $placeholder .= '<' . $tag;
1878 $placeholder .= ' ' . $data_url_attr . '="' . esc_url( $url ) . '"';
1879 $placeholder .= ' data-surecookie-name="' . esc_attr( $name ) . '"';
1880 $placeholder .= ' data-surecookie-category="' . esc_attr( $mapped_category ) . '"';
1881
1882 $trimmed_attrs = trim( (string) $new_attributes );
1883 if ( $trimmed_attrs !== '' ) {
1884 $placeholder .= ' ' . $trimmed_attrs;
1885 }
1886
1887 $placeholder .= ' style="' . esc_attr( $combined_style ) . '"';
1888
1889 if ( $tag === 'embed' ) {
1890 // <embed> is void / self-closing.
1891 $placeholder .= ' />';
1892 } else {
1893 $placeholder .= '>' . $inner . '</' . $tag . '>';
1894 }
1895
1896 $placeholder .= '</div>';
1897
1898 return $placeholder;
1899 }
1900
1901 /**
1902 * Map API category names to SureCookie category names.
1903 *
1904 * Reached from the output-buffer display handler, where PHP forbids opening a
1905 * buffer: an `ob_start()` in a callback is an uncatchable fatal that discards
1906 * the whole response. Keep callbacks to pure string work.
1907 *
1908 * @param string $category Original category name.
1909 * @since 0.0.1
1910 * @return string Mapped category name.
1911 */
1912 private function map_category( string $category ): string {
1913 $filtered = apply_filters( 'surecookie_map_category', $category, $category );
1914 return is_string( $filtered ) ? $filtered : $category;
1915 }
1916
1917 /**
1918 * Check if this request is from the SaaS scanner with a valid bypass token.
1919 *
1920 * @since 0.0.0-alpha.2
1921 * @since 0.0.1-beta.2 Strict 64-char hex validation; replaces sanitize_text_field().
1922 * @since 1.3.0 Delegates to Utils so integrations that gate outside the output buffer share it.
1923 * @return bool
1924 */
1925 private function is_scan_bypass_request(): bool {
1926 return Utils::is_scan_bypass_request();
1927 }
1928 }
1929