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.
}
}