]+))'; /** * The same three spellings, uncaptured, for strip-the-attribute rewrites. * * @since 1.5.0 */ private const ATTR_VALUE_ANY = '(?:"[^"]*"|\'[^\']*\'|[^\s>]+)'; /** * Script `type` values the browser executes. Mirrors the allowlist in * `src/utils/consentManager.js` so blocker output round-trips correctly. */ private const EXECUTABLE_SCRIPT_TYPES = [ 'text/javascript', 'module', 'application/javascript', 'application/ecmascript', 'text/ecmascript', 'importmap', 'speculationrules' ]; /** * Types that list other resources rather than carry tracker code, so a * pattern hit inside one is always collateral. Still in * EXECUTABLE_SCRIPT_TYPES, which consentManager.js validates against. * * @since 1.5.0 */ private const MANIFEST_SCRIPT_TYPES = [ 'importmap', 'speculationrules' ]; /** * Marker comment printed at the very start of `wp_footer`, giving the * buffer processor a precise footer boundary for region-constrained rules. * Always stripped from the final output. */ private const FOOTER_MARKER = ''; /** * Largest `data:` script payload worth decoding for pattern matching, in bytes. * * @since 1.5.0 */ private const MAX_DATA_URI_PAYLOAD = 262144; /** * `` values that reach the href host before consent, by fetching * it (stylesheet and the preload family) or by opening a connection to it * (preconnect, dns-prefetch) - which is the transfer the Google Fonts * rulings are about, not the file. * * An allowlist, and it has to stay one. `canonical`, `profile` and * `alternate` are on nearly every WordPress page and name a URL the browser * never requests; `icon` and `manifest` do fetch, but a missing favicon * reads as a broken site and no catalog service delivers one. */ private const FETCHING_LINK_RELS = [ 'stylesheet', 'preload', 'modulepreload', 'prefetch', 'prerender', 'preconnect', 'dns-prefetch' ]; /** * Memoized core asset bases as [ host, path ]. See core_bases(). * * @var array|null */ private static ?array $core_bases = null; /** * Per-request cache of iframe-pattern lookup map (shared across * block_iframes / block_embeds / block_objects). * * @var array|null */ private ?array $iframe_patterns_cache = null; /** * Per-request cache of the link-pattern lookup map. * * @var array|null */ private ?array $link_patterns_cache = null; /** * Whether the processing buffer has been opened for this request. * * @var bool */ private bool $buffer_open = false; /** * Memoized should_process() verdict. * * It fires `surecookie_scanner_request_detected`, and the buffer is now * attempted on two hooks, so an unmemoized false verdict double-counted * every scanner request. * * @var bool|null */ private ?bool $should_process = null; /** * Constructor. * * @since 0.0.1 */ private function __construct() { $this->register_hooks(); } /** * Open a full-page output buffer for the resolved template so the finished * HTML can be post-processed, then return the template unchanged so * WordPress renders it normally. * * Untyped by design: this runs at PHP_INT_MAX, so every other callback on * template_include has already returned. Core itself does not trust this * value (see template-loader.php), and returning it untouched keeps a * non-string from another plugin intact. * * @param mixed $template Expected string path to the resolved template. * @since 0.0.1 * @since 1.2.2 Buffer the real template output instead of pre-rendering it, * so WordPress 7.0's on-demand block-style hoisting is preserved. * @return mixed The template, untouched. */ public function intercept_template( $template = '' ) { if ( ! is_string( $template ) || $template === '' || ! file_exists( $template ) ) { return $template; } /* * Open a full-page output buffer and let WordPress include the *real* * template normally, then post-process the finished HTML in the buffer * callback. * * We must NOT render the template ourselves here. Rendering the page * inside this filter (and returning a blank template) runs ahead of * WordPress 7.0's own template-enhancement output buffer, which is * started later at the `wp_before_include_template` action and hoists * on-demand block styles from the body back up into the . Doing * our own early render bypassed that hoisting, leaving classic-theme * block/global styles stranded in the in the wrong cascade order * (e.g. the theme's `body .is-layout-grid{display:grid}` then overrode a * block's responsive `display:flex`, collapsing multi-column layouts). * * By opening our buffer first and returning the real template, WordPress * renders and enhances the page as usual - its buffer nests inside ours - * and our callback processes the already-corrected HTML. */ $this->start_buffer(); return $template; } /** * `template_redirect` callback: open the buffer before any plugin that * renders a page from this action can finish and exit. * * @since 1.5.0 * @return void */ public function open_buffer(): void { $this->start_buffer(); } /** * Process the output buffer. * * @param string $buffer HTML content. * @param int $phase Bitmask PHP passes to an output handler. The CLEAN bit is * set when the buffer is being discarded rather than sent. * @since 0.0.1 * @return string Modified HTML. */ public function process_buffer( string $buffer, int $phase = 0 ): string { // PHP sets the CLEAN bit when the buffer is being discarded rather than sent, // which is what a third party's `while ( ob_get_level() ) ob_end_clean()` does // to us. Our return value is thrown away in that case, so the page ships with // no blocking applied and nothing else records it. Core reads the same bit in // wp_finalize_template_enhancement_output_buffer(). See #1082. if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) { Logger::get_instance()->log( 'SureCookie: the blocking buffer was discarded by another plugin before it could be sent, so this response shipped unblocked.', 'error' ); return $buffer; } // Skip empty buffers. if ( empty( $buffer ) ) { return $buffer; } // Check if this is HTML content. if ( ! $this->is_html( $buffer ) ) { return $buffer; } // Block scripts and embedded content (iframe/embed/object) if blocking is enabled. if ( Utils::is_blocking_enabled() ) { $buffer = $this->block_scripts( $buffer ); $buffer = $this->block_iframes( $buffer ); $buffer = $this->block_embeds( $buffer ); $buffer = $this->block_objects( $buffer ); $buffer = $this->block_links( $buffer ); /** * Filter the blocked page HTML, for integrations that must gate markup * the tag-level passes above cannot see - a page builder that carries an * embed as widget config and builds the iframe in the browser, say. * * Runs inside `should_process()`, so the scan bypass, geo rules and the * admin/AJAX/REST guards already apply to every callback. * * Callbacks run inside an output-buffer display handler. PHP forbids opening * a buffer there, so an `ob_start()` in a callback raises an uncatchable fatal * that discards the entire response. Keep callbacks to pure string work. * * @since 1.4.0 * @param string $buffer Page HTML after the built-in blocking passes. */ $buffer = (string) apply_filters( 'surecookie_blocked_buffer', $buffer ); // Last, so block_scripts() never sees it: the guard carries the whole // pattern catalog inline, which would self-match and neutralize it. // Injection position, not processing order, is what puts it first in // the finished document. $buffer = Dom_Guard::get_instance()->inject( $buffer ); } // The footer-boundary marker is internal - never ship it. return str_replace( self::FOOTER_MARKER, '', $buffer ); } /** * Print the footer-boundary marker consumed by split_html_regions(). * * @since 1.3.0 * @return void */ public function mark_footer_start(): void { if ( Utils::is_blocking_enabled() ) { echo self::FOOTER_MARKER; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static HTML comment constant. } } /** * Core asset bases as [ host, path ] pairs, resolved once per request. * * Memoized: is_core_asset() runs per tag and nothing here changes mid-request. * Shared with Dom_Guard so both layers agree on which host is ours. * * @since 1.5.0 * @return array */ public static function core_bases(): array { if ( self::$core_bases !== null ) { return self::$core_bases; } $bases = []; foreach ( [ includes_url(), admin_url() ] as $base ) { $parts = wp_parse_url( $base ); $parts = is_array( $parts ) ? $parts : []; $path = (string) ( $parts['path'] ?? '' ); if ( $path === '' ) { continue; } $bases[] = [ self::without_www( strtolower( (string) ( $parts['host'] ?? '' ) ) ), $path ]; } self::$core_bases = $bases; return $bases; } /** * Drop a leading `www.` so a host comparison is not defeated by the prefix. * * @param string $host Lowercased host. * @since 1.5.0 * @return string */ public static function without_www( string $host ): string { return Entry_Match::without_www( $host ); } /** * Open the processing buffer once per request. * * Opening it before WordPress 7.0's own template-enhancement buffer (started * at `wp_before_include_template`) keeps ours on the outside, so block-style * hoisting still runs and we post-process the corrected HTML. * * @since 1.5.0 * @return void */ private function start_buffer(): void { if ( $this->buffer_open || ! $this->should_process() ) { return; } $this->buffer_open = true; ob_start( [ $this, 'process_buffer' ] ); } /** * Register WordPress hooks. * * @since 0.0.1 * @return void */ private function register_hooks(): void { // A plugin that owns a post type's templates can render the whole page // from template_redirect and exit, so template_include never fires and // the buffer never opens. Blocking is compliance-critical and must not be // cancellable that way, so open as early as a front-end request allows; // template_include stays as the fallback and no-ops if we already did. add_action( 'template_redirect', [ $this, 'open_buffer' ], -PHP_INT_MAX ); add_filter( 'template_include', [ $this, 'intercept_template' ], PHP_INT_MAX ); // Before every other wp_footer callback, so all footer scripts land // after the marker. Stripped again in process_buffer(). add_action( 'wp_footer', [ $this, 'mark_footer_start' ], -PHP_INT_MAX ); } /** * Check if blocking should run. * * @since 0.0.1 * @return bool */ private function should_process(): bool { if ( $this->should_process === null ) { $this->should_process = $this->evaluate_should_process(); } return $this->should_process; } /** * Decide, once per request, whether blocking runs. * * @since 0.0.1 * @return bool */ private function evaluate_should_process(): bool { // Check if blocking feature is enabled. if ( ! Utils::is_blocking_enabled() ) { return false; } // Skip in admin area. if ( is_admin() ) { return false; } // Skip AJAX requests. if ( wp_doing_ajax() ) { return false; } // Skip REST API requests. if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return false; } // Skip feeds. if ( is_feed() ) { return false; } if ( $this->is_editor_bypass() ) { return false; } // Check content type header. $headers_list = headers_list(); foreach ( $headers_list as $header ) { if ( stripos( $header, 'Content-Type:' ) === 0 ) { // Skip JSON responses. if ( stripos( $header, 'application/json' ) !== false ) { return false; } // Skip XML responses. if ( stripos( $header, 'application/xml' ) !== false || stripos( $header, 'text/xml' ) !== false ) { return false; } } } // 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. if ( ! Utils::should_process_based_on_geo() ) { return false; } // Allow the SaaS scanner to bypass blocking so it sees the full unblocked page. if ( $this->is_scan_bypass_request() ) { // Record that the scanner's request actually reached WordPress. If a // scan finishes with zero findings and zero reach, the host firewall // blocked the crawl at the edge (see SaasClient::classify_scan_outcome). do_action( 'surecookie_scanner_request_detected' ); return false; } /** * Filter whether blocking should run. * * Reached from the output-buffer display handler, where PHP forbids opening a * buffer: an `ob_start()` in a callback is an uncatchable fatal that discards * the whole response. Keep callbacks to pure string work. * * @since 0.0.1 * @param bool $should_process Whether to process the output. */ $filtered = apply_filters( 'surecookie_should_block_scripts', true ); return is_bool( $filtered ) ? $filtered : true; } /** * Whether blocking is skipped for a frontend builder's own render. * * Frontend builders edit where is_admin() is false, so blocking broke their * tooling (issue #1033). Bailing turns off both the buffer rewrite and the * DOM guard, so the capability alone is not enough: staff browsing normally * are visitors, and a bypass would release even the quarantine category. * * @since 1.5.0 * @return bool */ private function is_editor_bypass(): bool { if ( ! Helper::is_builder_edit_render() ) { return false; } /** * Filter whether logged-in users who can edit content bypass blocking. * Return false to keep consent-blocking scripts for editing staff. * * @since 1.5.0 * @param bool $bypass Whether editors bypass blocking. Default true. */ if ( ! apply_filters( 'surecookie_bypass_blocking_for_editors', true ) ) { return false; } // An unblocked render must never reach visitors from a full-page cache. if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); } return true; } /** * Check if buffer is HTML content. * * @param string $buffer Content to check. * @since 0.0.1 * @return bool */ private function is_html( string $buffer ): bool { // ltrim() does not strip a UTF-8 BOM, and a single byte before the doctype // would otherwise turn blocking off for the entire page, silently. if ( str_starts_with( $buffer, "\xEF\xBB\xBF" ) ) { $buffer = substr( $buffer, 3 ); } $trimmed = ltrim( $buffer ); // Check if starts with HTML-like content. if ( empty( $trimmed ) ) { return false; } // Skip JSON (starts with { or [). if ( $trimmed[0] === '{' || $trimmed[0] === '[' ) { return false; } // Skip XML. The buffer now opens at template_redirect, which is where // core renders wp-sitemap.xsl, and that document carries a the // guard would inject a raw '; return $new_tag; } /** * Extract the normalized script `type` from a tag's attribute string. * * Handles quoted (`type="module"`, `type='module'`) and unquoted HTML5 * (`type=module`) forms, lowercases, trims whitespace, and strips MIME * parameters (`text/javascript; charset=utf-8` → `text/javascript`). * * @param string $attributes Raw attributes string from the opening tag. * @since 0.0.1-beta.2 * @return string|null Normalized type, or null when no (or empty) type attribute. */ private function extract_script_type( string $attributes ): ?string { if ( ! preg_match( '/type\s*=(?|\s*"([^"]*)"|\s*\'([^\']*)\'|([^\s>]+))/i', $attributes, $match ) ) { return null; } if ( $match[1] === '' ) { return null; } return strtolower( trim( explode( ';', $match[1], 2 )[0] ) ); } /** * Build an embedded-content placeholder for a blocked iframe/embed/object tag. * * Renders the same overlay UX ("This content is blocked… Accept & Load") * for all three tag types; only the hidden inner element differs. The URL * is stored in `data-surecookie-src` for iframe/embed (both use `src=`) and * `data-surecookie-data` for object (uses `data=` attribute). consentManager.js * restores the URL when the user consents. * * @param string $tag One of 'iframe', 'embed', 'object'. * @param string $attributes Original tag attributes string. * @param string $url Original resource URL (src for iframe/embed, data for object). * @param string $name Matched service key. * @param string $category Matched category. * @param string $label Human-readable vendor label. * @param string $inner For , the neutralized inner HTML. Ignored otherwise. * @since 0.0.1-beta.2 * @return string Placeholder HTML. */ private function build_embedded_placeholder( string $tag, string $attributes, string $url, string $name, string $category, string $label, string $inner = '' ): string { $mapped_category = $this->map_category( $category ); // Normalize tag. $tag = in_array( $tag, [ 'iframe', 'embed', 'object' ], true ) ? $tag : 'iframe'; // Strip the URL attribute from the original attributes.