]+))';
/**
* 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