is neutralized on * output (type="text/plain" + data-cookroco-cat) for EVERY visitor regardless of their * stored choice. The front-end JS then re-activates, on load, the scripts whose category * the visitor has already accepted. Because the served HTML is identical for everyone, * full-page/CDN caching does not interfere; per-visitor consent is applied in the browser. * * Google tags additionally receive Google Consent Mode v2 default:denied in before * anything loads — Google's own recommended consent gate, effective even without blocking. * * @since 1.1.0 */ class COOKROCO_Blocker { private bool $gcm; private bool $block_enqueued; private bool $buffer_html; public function __construct() { $s = (array) get_option( 'cookroco_settings', [] ); // (array) cast: a scalar option (corrupt/imported) would TypeError the array-typed enabled(). $this->gcm = $this->enabled( $s, 'gcm_enabled', true ); $this->block_enqueued = $this->enabled( $s, 'block_scripts', true ); $this->buffer_html = $this->enabled( $s, 'block_raw_html', false ); if ( $this->gcm ) { add_action( 'wp_head', [ $this, 'print_gcm_default' ], 0 ); } if ( $this->block_enqueued ) { add_filter( 'script_loader_tag', [ $this, 'block_enqueued_script' ], 20, 3 ); } if ( $this->buffer_html ) { add_action( 'template_redirect', [ $this, 'start_buffer' ], 1 ); } } /** Read a boolean setting, defaulting when the key is absent (so updates from older versions opt in). */ private function enabled( array $s, string $key, bool $default ): bool { return array_key_exists( $key, $s ) ? ! empty( $s[ $key ] ) : $default; } /** * Google Consent Mode v2: set every storage type to denied before any Google tag loads. * * A returning visitor's decision is applied right here too, read from the consent cookie * before any tag runs. Waiting for the deferred banner script let Google's tags fire their * first hits as "denied" on slow phones, losing visits from people who had already said yes. * The cookie is the visitor's own, so a cached page still serves each visitor their decision. * Only the cookie name and the policy version are written in: the same for every visitor. */ public function print_gcm_default(): void { $s = (array) get_option( 'cookroco_settings', [] ); $js = 'window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}'; $js .= "gtag('consent','default',{"; $js .= "ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied',"; $js .= "analytics_storage:'denied',personalization_storage:'denied',"; // Functional cookies are an optional category here, so their storage starts denied too and // follows that category, as the banner's update already did. $js .= "functionality_storage:'denied',security_storage:'granted',wait_for_update:500});"; $js .= '(function(n,v){try{var p=document.cookie.split("; "),r="",i;for(i=0;i 'cookroco-gcm-default' ] ); } /** * Known tracking vendors. Each entry: cat (category key), label, src[] (substrings matched * against a script's src), inline[] (substrings matched against an inline script body). * * NOTE: the Google Tag Manager container (gtm.js) is intentionally NOT listed — blocking the * container breaks all tag management. GCM default:denied (above) is the correct gate for * GTM-managed tags. Direct gtag.js / analytics.js loads ARE blocked. */ public static function vendors(): array { $base = self::built_in_vendors(); // Services the owner declares by hand. The plugin knows a fixed list; any real site uses more. // Without this, anything absent from that list is neither blocked nor declared, and the // cookie notice stays incomplete with nothing to signal it. // Under their own prefix: a service declared as "Hotjar" or "Tidio" used to take the built-in // entry's place, and the built-in one, with its signatures, stopped being blocked. foreach ( self::custom_services() as $id => $svc ) { $base[ 'custom_' . $id ] = [ 'cat' => $svc['cat'], 'label' => $svc['label'], 'src' => $svc['src'], 'inline' => $svc['inline'], 'cookies' => array_column( $svc['cookies'], 'name' ), ]; } /** * The full catalog of recognized services. * Cookie Rocket Pro hooks its extended catalog in here. * * @param array $base [ id => [ cat, label, src[], inline[], cookies[] ] ] */ return (array) apply_filters( 'cookroco_vendors', $base ); } /** * Services declared by the site owner, sanitized and in the same shape as the built-in * ones. Cached per request: vendors() is called on every page load. * * @return array}> */ public static function custom_services(): array { static $cache = null; if ( null !== $cache ) { return $cache; } $cache = []; foreach ( (array) get_option( 'cookroco_custom_services', [] ) as $id => $svc ) { if ( ! is_array( $svc ) || empty( $svc['label'] ) ) { continue; } $id = sanitize_key( (string) $id ); if ( '' === $id ) { continue; } $cookies = []; foreach ( (array) ( $svc['cookies'] ?? [] ) as $c ) { if ( ! is_array( $c ) || empty( $c['name'] ) ) { continue; } $cookies[] = [ 'name' => sanitize_text_field( (string) $c['name'] ), 'purpose' => sanitize_text_field( (string) ( $c['purpose'] ?? '' ) ), 'duration' => sanitize_text_field( (string) ( $c['duration'] ?? '' ) ), ]; } $cache[ $id ] = [ 'label' => sanitize_text_field( (string) $svc['label'] ), 'cat' => in_array( $svc['cat'] ?? '', [ 'analytics', 'marketing', 'functional', 'personalization', 'social' ], true ) ? $svc['cat'] : 'analytics', 'src' => array_values( array_filter( array_map( 'sanitize_text_field', (array) ( $svc['src'] ?? [] ) ) ) ), 'inline' => array_values( array_filter( array_map( 'sanitize_text_field', (array) ( $svc['inline'] ?? [] ) ) ) ), 'cookies' => $cookies, ]; } return $cache; } /** The nine providers the plugin ships with. */ private static function built_in_vendors(): array { return [ 'google_analytics' => [ 'cat' => 'analytics', 'label' => 'Google Analytics (gtag.js / analytics.js)', 'src' => [ 'googletagmanager.com/gtag/js', 'google-analytics.com/analytics.js', 'google-analytics.com/ga.js', 'stats.g.doubleclick.net' ], 'inline' => [ "gtag('config'", 'gtag("config"', "gtag('js'", "ga('create'", '__gaTracker' ], 'cookies' => [ '_ga', '_ga_*', '_gid', '_gat', '_gat_*', '__utma', '__utmb', '__utmc', '__utmt', '__utmz' ], ], 'meta_pixel' => [ 'cat' => 'marketing', 'label' => 'Meta / Facebook Pixel', 'src' => [ 'fbevents.js' ], // the bare host would also match the social SDK 'inline' => [ "fbq('init'", 'fbq("init"', 'connect.facebook.net/en_US/fbevents.js' ], 'cookies' => [ '_fbp', '_fbc', 'fr' ], ], 'hotjar' => [ 'cat' => 'analytics', 'label' => 'Hotjar', 'src' => [ 'static.hotjar.com', 'script.hotjar.com' ], 'inline' => [ '_hjSettings', 'hotjar.com' ], 'cookies' => [ '_hj*' ], ], 'ms_clarity' => [ 'cat' => 'analytics', 'label' => 'Microsoft Clarity', 'src' => [ 'clarity.ms/tag' ], 'inline' => [ 'clarity.ms/tag', '(c,l,a,r,i,t,y)' ], 'cookies' => [ '_clck', '_clsk', 'CLID', 'MUID', 'SM' ], ], 'linkedin' => [ 'cat' => 'marketing', 'label' => 'LinkedIn Insight Tag', 'src' => [ 'snap.licdn.com' ], 'inline' => [ '_linkedin_partner_id', '_linkedin_data_partner_ids' ], 'cookies' => [ 'li_sugr', 'lidc', 'bcookie', 'bscookie', 'UserMatchHistory' ], ], 'tiktok' => [ 'cat' => 'marketing', 'label' => 'TikTok Pixel', 'src' => [ 'analytics.tiktok.com' ], 'inline' => [ 'ttq.load', 'ttq.page', 'analytics.tiktok.com' ], 'cookies' => [ '_ttp' ], ], 'twitter' => [ 'cat' => 'marketing', 'label' => 'X / Twitter Pixel', 'src' => [ 'static.ads-twitter.com', 'analytics.twitter.com' ], 'inline' => [ "twq('init'", "twq('config'", 'twq("config"' ], 'cookies' => [ 'personalization_id' ], ], 'google_ads' => [ 'cat' => 'marketing', 'label' => 'Google Ads / DoubleClick', 'src' => [ 'googleadservices.com/pagead', 'googlesyndication.com', 'doubleclick.net' ], 'inline' => [ 'google_conversion_id', 'gtag_report_conversion' ], 'cookies' => [ '_gcl_au', '_gcl_aw', 'IDE', 'test_cookie' ], ], 'pinterest' => [ 'cat' => 'marketing', 'label' => 'Pinterest Tag', 'src' => [ 's.pinimg.com' ], 'inline' => [ "pintrk('load'", 'pintrk(' ], 'cookies' => [ '_pin_unauth', '_routing_id', '_epik' ], ], // ── Extended coverage ──────────────────────────────────────────────────────── // The nine above are the large advertising and analytics platforms. These are the ones // a WordPress site actually carries: captchas, Automattic statistics, social embeds, // chats and share buttons. Each signature names domain AND path: a short fragment would // block third-party scripts, and every declared cookie is published on the site's // cookie policy, so only the ones the vendor itself documents are listed. 'jetpack_stats' => [ 'cat' => 'analytics', 'label' => 'Jetpack Stats (WordPress.com)', 'src' => [ 'stats.wp.com/e-' ], 'inline' => [ '_stq.push(', 'window._stq', '_jpStatsFire' ], 'cookies' => [], ], 'woocommerce_analytics' => [ 'cat' => 'analytics', 'label' => 'WooCommerce Analytics (Automattic)', 'src' => [ 'stats.wp.com/s-' ], 'inline' => [ 'window._wca', '_wca.push(', 'wcAnalytics.assets_url', '_wca_prevent_referrer' ], 'cookies' => [ 'tk_ai', 'tk_aip', 'tk_qs', 'tk_or', 'tk_lr', 'tk_r3d', 'woocommerceanalytics_session' ], ], 'chatra' => [ 'cat' => 'functional', 'label' => 'Chatra', 'src' => [ 'call.chatra.io/chatra.js' ], 'inline' => [ 'ChatraID', 'window.Chatra' ], 'cookies' => [ 'Chatra.clientId', 'Chatra.referrer', 'Chatra.lastPageViewAt' ], ], 'cleantalk' => [ 'cat' => 'functional', 'label' => 'CleanTalk Anti-Spam', 'src' => [ 'cleantalk.org/ct-bot-detector-wrapper.js', 'cleantalk-spam-protect/js/apbct-public-bundle' ], 'inline' => [ 'var ctPublicFunctions', 'var ctPublic =' ], 'cookies' => [ 'apbct_*', 'ct_checkjs', 'ct_timezone', 'ct_screen_info', 'ct_pointer_data', 'ct_ps_timestamp', 'ct_fkp_timestamp', 'ct_has_scrolled', 'ct_mouse_moved', 'ct_has_key_up', 'ct_has_input_focused', 'ct_checked_emails', 'ct_sfw_*' ], ], 'cloudflare_turnstile' => [ 'cat' => 'functional', 'label' => 'Cloudflare Turnstile', 'src' => [ 'challenges.cloudflare.com/turnstile/' ], // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- a signature matched against the site's own script URLs; nothing is loaded from it. 'inline' => [ 'turnstile.render(', 'turnstile.execute(' ], 'cookies' => [], ], 'crowdsignal' => [ 'cat' => 'functional', 'label' => 'Crowdsignal / Polldaddy (Automattic)', 'src' => [ 'polldaddy.com/p/', 'polldaddy.com/js/rating/', 'app.crowdsignal.com/js/rating/', 'app.crowdsignal.com/survey.js' ], 'inline' => [ 'PDRTJS_settings', 'PDRTJS_RATING(' ], 'cookies' => [ 'PDRTJS_*', 'PD_poll_*' ], ], 'google_recaptcha' => [ 'cat' => 'functional', 'label' => 'Google reCAPTCHA', 'src' => [ 'google.com/recaptcha/', 'recaptcha.net/recaptcha/', 'gstatic.com/recaptcha/' ], // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- signatures matched against the site's own script URLs; nothing is loaded from them. 'inline' => [ 'recaptcha/api.js', 'recaptcha/enterprise.js' ], 'cookies' => [ '_GRECAPTCHA' ], ], 'gravatar' => [ 'cat' => 'functional', 'label' => 'Gravatar Hovercards (Automattic)', 'src' => [ 'gravatar.com/js/gprofiles.js', 'gravatar.com/js/hovercards/' ], 'inline' => [ 'Gravatar.init(' ], 'cookies' => [], ], 'hcaptcha' => [ 'cat' => 'functional', 'label' => 'hCaptcha', 'src' => [ 'hcaptcha.com/1/api.js' ], 'inline' => [ 'hcaptcha.render(', 'hcaptcha.execute(' ], 'cookies' => [ 'hc_accessibility', 'hmt_id' ], ], 'jivochat' => [ 'cat' => 'functional', 'label' => 'JivoChat', 'src' => [ 'jivosite.com/widget/', 'jivosite.com/script/widget/' ], 'inline' => [ 'jivosite.com', 'jivo_api' ], 'cookies' => [ 'jv_*' ], ], 'livechat' => [ 'cat' => 'functional', 'label' => 'LiveChat', 'src' => [ 'cdn.livechatinc.com/tracking.js' ], 'inline' => [ '__lc.license', 'LiveChatWidget' ], 'cookies' => [], ], 'smartsupp' => [ 'cat' => 'functional', 'label' => 'Smartsupp', 'src' => [ 'smartsuppchat.com/loader.js' ], 'inline' => [ '_smartsupp.key', 'smartsuppchat.com/loader.js' ], 'cookies' => [ 'ssupp.vid', 'ssupp.visits' ], ], 'tidio' => [ 'cat' => 'functional', 'label' => 'Tidio', 'src' => [ 'code.tidio.co/' ], 'inline' => [ 'tidioChatCode', 'tidioChatApi' ], 'cookies' => [], ], 'dailymotion' => [ 'cat' => 'marketing', 'label' => 'Dailymotion (libs/player.js)', 'src' => [ 'geo.dailymotion.com/libs/player/' ], 'inline' => [ 'dailymotion.createPlayer' ], 'cookies' => [ 'v1st', 'ts', 'dmvk' ], ], 'hellobar' => [ 'cat' => 'marketing', 'label' => 'Hello Bar', 'src' => [ 'my.hellobar.com/', 'scripts.hellobar.com/' ], 'inline' => [], 'cookies' => [], ], 'sharethis' => [ 'cat' => 'marketing', 'label' => 'ShareThis', 'src' => [ 'platform-api.sharethis.com/', 'ws.sharethis.com/button/', 'w.sharethis.com/button/', 'sharethis.com/js/sharethis.js', 'sharethis.com/button/buttons.js' ], 'inline' => [ '__sharethis__', 'stLight.options', 'stLight.options(' ], 'cookies' => [ 'fpestid', '__unam', '__stid', '__stidv' ], ], 'soundcloud_player' => [ 'cat' => 'marketing', 'label' => 'SoundCloud (embedded player)', 'src' => [ 'w.soundcloud.com/player', 'widget.sndcdn.com/widget' ], 'inline' => [ 'SC.Widget(' ], 'cookies' => [ 'sc_anonymous_id' ], ], 'spotify' => [ 'cat' => 'marketing', 'label' => 'Spotify', 'src' => [ 'open.spotify.com/embed', 'embed.spotify.com/', 'embed-cdn.spotifycdn.com/' ], 'inline' => [ 'SpotifyIframeConfig', 'onSpotifyIframeApiReady' ], 'cookies' => [ 'sp_t', 'sp_landing' ], ], 'sumo' => [ 'cat' => 'marketing', 'label' => 'BDOW! / Sumo (formerly SumoMe)', 'src' => [ 'load.sumome.com/', 'load.bdow.com/', 'load.sumo.com/' ], 'inline' => [ 'sumoSiteId', 'data-sumo-site-id' ], 'cookies' => [ '__smVID', '__smSubscribed', '__smListBuilderShown', '__smListBuilderOptOut', '__smSmartBarShown', '__smSmartBarOptOut', '__smScrollBoxShown', '__smScrollBoxOptOut', '__smWelcomeMatShown', '__smWelcomeMatOptOut' ], ], 'addtoany' => [ 'cat' => 'social', 'label' => 'AddToAny Share Buttons', 'src' => [ 'addtoany.com/menu/', 'addtoany/page.js' ], 'inline' => [ 'a2a_config' ], 'cookies' => [], ], 'disqus' => [ 'cat' => 'social', 'label' => 'Disqus', 'src' => [ 'disqus.com/embed.js', 'disqus.com/count.js', 'disquscdn.com/' ], 'inline' => [ 'disqus_config', 'disqus_shortname', 'disqus.com/embed.js' ], 'cookies' => [ 'disqus_unique', '__qca', 'testCookie' ], ], 'facebook_sdk' => [ 'cat' => 'social', 'label' => 'Meta / Facebook SDK (sdk.js, social plugins)', 'src' => [ 'connect.facebook.net/en_US/sdk', 'connect.facebook.net/en_GB/sdk', 'connect.facebook.net/es_ES/sdk', 'connect.facebook.net/es_LA/sdk', 'connect.facebook.net/es_MX/sdk', 'connect.facebook.net/pt_BR/sdk', 'connect.facebook.net/fr_FR/sdk', 'connect.facebook.net/de_DE/sdk', 'connect.facebook.net/it_IT/sdk', 'facebook.com/plugins/' ], 'inline' => [ 'fbAsyncInit', 'connect.facebook.net/en_US/sdk.js' ], 'cookies' => [ 'fbsr_*' ], ], 'instagram_embed' => [ 'cat' => 'social', 'label' => 'Instagram (embedded content)', 'src' => [ 'instagram.com/embed.js', 'platform.instagram.com/' ], 'inline' => [ 'instgrm.Embeds', 'window.instgrm', 'instagram.com/embed.js', 'data-instgrm-' ], 'cookies' => [ 'csrftoken', 'mid' ], ], 'twitch' => [ 'cat' => 'social', 'label' => 'Twitch', 'src' => [ 'player.twitch.tv/', 'clips.twitch.tv/embed', 'twitch.tv/embed/', 'embed.twitch.tv/' ], 'inline' => [ 'new Twitch.Embed(', 'new Twitch.Player(' ], 'cookies' => [ 'unique_id', 'unique_id_durable', 'server_session_id', 'twitch.lohp.countryCode' ], ], 'x_widgets' => [ 'cat' => 'social', 'label' => 'X / Twitter Widgets (widgets.js)', 'src' => [ 'platform.twitter.com/widgets.js', 'platform.x.com/widgets.js' ], 'inline' => [ 'twttr.widgets', 'platform.twitter.com/widgets.js' ], 'cookies' => [], ], ]; } /** * Best-effort list of cookies to expire (client-side) per category when consent is * rejected or withdrawn. A trailing "*" matches by prefix. * * Every recognized service contributes its cookies under its own category: the built-in * ones, the ones the owner declares and, in Pro, the extended catalog. Necessary cookies * are never expired, nor are names so generic that the site itself may use them (the * provider sets those on its own domain, where the page cannot reach them anyway). */ public static function vendor_cookies(): array { $generic = [ 'ts', 'mid', 'csrftoken', 'testCookie', 'unique_id', 'server_session_id' ]; $map = self::category_cookies(); foreach ( self::vendors() as $v ) { $cat = (string) ( $v['cat'] ?? '' ); if ( '' === $cat || 'necessary' === $cat ) { continue; } foreach ( (array) ( $v['cookies'] ?? [] ) as $name ) { $name = (string) $name; if ( '' !== $name && ! in_array( $name, $generic, true ) && ! in_array( $name, $map[ $cat ] ?? [], true ) ) { $map[ $cat ][] = $name; } } } return $map; } /** * The common cookies of each category, for services the scanner finds without a cookie * list of their own. */ public static function category_cookies(): array { return [ 'analytics' => [ '_ga', '_ga_*', '_gid', '_gat', '_gat_*', '__utma', '__utmb', '__utmc', '__utmt', '__utmz', '_hj*', '_clck', '_clsk', 'CLID', 'MUID', 'SM' ], 'marketing' => [ '_fbp', '_fbc', 'fr', '_gcl_au', '_gcl_aw', 'IDE', 'test_cookie', '_ttp', 'li_sugr', 'lidc', 'bcookie', 'bscookie', 'UserMatchHistory', '_pin_unauth', '_routing_id' ], ]; } /** Block enqueued (wp_enqueue_script) third-party scripts whose src matches a known vendor. */ public function block_enqueued_script( string $tag, string $handle, string $src ): string { if ( '' === $src ) { return $tag; } if ( false !== strpos( $handle, 'cookie-rocket' ) ) { return $tag; // Never block our own banner script. } if ( false !== strpos( $tag, 'data-cookroco-cat' ) ) { return $tag; // Already neutralized — stay idempotent if the filter runs twice. } $cat = $this->match_src( $src ); if ( null === $cat ) { return $tag; } return $this->neutralize_tag( $tag, $cat ); } /** Return the category for a src that matches a known vendor, or null. */ private function match_src( string $src ): ?string { foreach ( self::vendors() as $key => $v ) { foreach ( $v['src'] as $needle ) { if ( false !== strpos( $src, $needle ) && $this->block_vendor( $key, $v['cat'] ) ) { return $v['cat']; } } } return null; } /** * Whether a matched vendor should be blocked server-side. Defaults to true, so the free * behavior is unchanged. Cookie Rocket Pro filters this in Google Consent Mode "advanced" * mode to let Google's own tags load in a default-denied state (for conversion modeling) * instead of neutralizing them. * * @param string $key Vendor key in vendors(). * @param string $cat Consent category of the vendor. * @return bool */ private function block_vendor( string $key, string $cat ): bool { return (bool) apply_filters( 'cookroco_block_vendor', true, $key, $cat ); } /** * Neutralizes every #is', function ( $m ) use ( $vendors ) { $attrs = $m[1]; $body = $m[2]; // Never touch our own scripts (GCM bootstrap, banner config, etc.). if ( false !== stripos( $attrs . $body, 'cookroco' ) || false !== stripos( $body, 'CookieRocket' ) ) { return $m[0]; } // Only neutralize executable scripts (no type, or a JavaScript type). if ( preg_match( '/\stype\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))/i', $attrs, $tm ) ) { $t = strtolower( trim( $tm[1] . ( $tm[2] ?? '' ) . ( $tm[3] ?? '' ) ) ); // Strip any ";charset=…" parameter, then treat EVERY JavaScript/ECMAScript MIME the // browser executes as executable — including the legacy x-/jscript/livescript aliases // and versioned text/javascript1.x — so a tracker cannot dodge blocking by declaring // one. A blank type is a classic executable script; a genuinely non-executable type // (JSON, importmap, template, speculationrules…) is left alone. $base = ( false !== ( $semi = strpos( $t, ';' ) ) ) ? trim( substr( $t, 0, $semi ) ) : $t; $js_mimes = [ '', 'module', 'text/javascript', 'application/javascript', 'text/ecmascript', 'application/ecmascript', 'text/x-javascript', 'application/x-javascript', 'text/x-ecmascript', 'application/x-ecmascript', 'text/jscript', 'text/livescript' ]; if ( ! in_array( $base, $js_mimes, true ) && 0 !== strpos( $base, 'text/javascript1.' ) ) { return $m[0]; // non-executable type: leave it alone. } } // Match by src first, then by inline body signature. $cat = null; if ( preg_match( '/\ssrc\s*=\s*(["\']?)([^"\'>\s]+)\1/i', $attrs, $sm ) ) { $cat = $this->match_src( $sm[2] ); } if ( null === $cat ) { foreach ( $vendors as $key => $v ) { foreach ( $v['inline'] as $needle ) { if ( false !== strpos( $body, $needle ) && $this->block_vendor( $key, $v['cat'] ) ) { $cat = $v['cat']; break 2; } } } } if ( null === $cat ) { return $m[0]; } return $this->neutral_open( $attrs, $cat ) . $body . ''; }, $html ); return null === $out ? $html : $out; // On PCRE failure, return the page untouched. } }