( $output_container_code && ! $this->should_output_container() ) { $output_container_code = false; if ( ! $no_console_log ) { $this->script_tag->print_script_block( $this->container_suppressed_warning() ); } } $excluded_role = $this->excluded_user_role(); if ( '' !== $excluded_role ) { $output_container_code = false; if ( ! $no_console_log ) { $this->script_tag->print_script_block( $this->disabled_role_warning( $excluded_role ) ); } } if ( $this->consent->enabled() ) { $this->script_tag->print_script_block( $this->consent->script_block( $this->script_tag, $datalayer_name ) ); } if ( ( array() !== $containers ) && $output_container_code ) { foreach ( $containers as $one_container ) { $one_gtm_id = (string) ( $one_container[ ContainerRows::COLUMN_ID ] ?? '' ); if ( ! preg_match( ContainerRows::GTM_ID_PATTERN, $one_gtm_id ) ) { continue; } $this->script_tag->print_script_block( $this->container_loader( $one_container, $datalayer_name ) ); } } do_action( GTM4WP_WPACTION_AFTER_CONTAINER_CODE ); echo ' '; } /** * Builds the header comment, the data layer initialization variable and * the first push of the compiled data layer content. Compiling the data * layer here also populates the backward compatible global and the * DataLayer::compiled() cache used by the AMP module. * * @param array> $containers Normalized container rows. * @param string $datalayer_name Data layer JS variable name. * @return string */ private function datalayer_block( array $containers, string $datalayer_name ): string { $script_tag = ' '; if ( array() !== $containers ) { $gtm4wp_datalayer_data = $this->datalayer->compile(); // Encode <, >, &, " and ' as \uXXXX so the data layer JSON is safe in // any inline-script context. This is defense in depth on top of // ScriptTag::print_script_block(): even a value that arrives here already // HTML-entity encoded (e.g. get_search_query() returns esc_attr'd output, // so a " becomes ") can never break out of the JS string literal. // No JSON_NUMERIC_CHECK here: it coerced every numeric-looking string // anywhere in the structure into a JSON number, silently corrupting // identifier-like values (a SKU of "000035180" lost its leading zeros; // order numbers, postcodes and phone numbers changed type). Values that // really are numbers (prices, totals, counts) are typed at their source // instead - the same contract the additional-push and cart-fragments // sinks have always had, so all sinks now agree on types. // json_object() keeps the top level an object literal: an empty or // list array would encode as `[...]`, which GTM reads as a command // array, not a message. Nested arrays are untouched. $datalayer_json = wp_json_encode( ScriptTag::json_object( $gtm4wp_datalayer_data ), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS ); // Omit the whole block rather than emit a literal we do not have (#141). // wp_json_encode() returns false for a value it cannot encode - INF/NAN, // a resource, or nesting past its depth limit, all of which reach the // data layer only through the public compile filter - and PHP renders // false as '', so concatenating it here would emit // `var dataLayer_content = ;`: a SyntaxError that takes this whole block, // the data layer initialization in it, and every container loader after // it. Dropping the push instead costs one page's data layer content and // leaves the container loading. See ScriptTag::json_literal() for the // assignment-position half of this rule. Empty content keeps the variable // for custom code reading it, but is not pushed. if ( false !== $datalayer_json ) { $script_tag .= ' ' . $this->script_tag->opening_tag() . ' var dataLayer_content = ' . $datalayer_json . ';'; if ( array() !== $gtm4wp_datalayer_data ) { $script_tag .= ' ' . esc_js( $datalayer_name ) . '.push( dataLayer_content );'; } $script_tag .= ' '; } } return $script_tag; } /** * Console warning shown when the container code placement is set to OFF but * the data layer is still active. * * @return string */ private function console_off_warning(): string { return ' ' . $this->script_tag->opening_tag() . ' console.warn && console.warn("[GTM4WP] Google Tag Manager container code placement set to OFF !!!"); console.warn && console.warn("[GTM4WP] Data layer codes are active but GTM container must be loaded using custom coding !!!"); '; } /** * Console warning shown when the container code is omitted because the * current user has an excluded role. * * @param string $user_role The excluded role that matched the current user. * @return string */ private function disabled_role_warning( string $user_role ): string { // json_literal(), not esc_js(): this is a string VALUE in a raw '; } /** * Console warning shown when the container code is suppressed by the * production-only option or the gtm4wp_output_container filter (the kill * switch) while the data layer stays active. * * @return string */ private function container_suppressed_warning(): string { return ' ' . $this->script_tag->opening_tag() . ' console.warn && console.warn("[GTM4WP] Google Tag Manager container code output has been suppressed on this environment !!!"); console.warn && console.warn("[GTM4WP] Data layer codes are active but GTM container code is omitted !!!"); '; } /** * Builds the GTM container loader snippet for one container row. The * domain, path and ID are validated/escaped by their helper methods. * * @param array $one_container One normalized container row. * @param string $datalayer_name Data layer JS variable name. * @return string */ private function container_loader( array $one_container, string $datalayer_name ): string { $one_gtm_id = (string) ( $one_container[ ContainerRows::COLUMN_ID ] ?? '' ); $_gtm_env = $this->container_environment( $one_container ); // Server side GTM containers can be configured to serve a // single container from the loader path itself; in that case the // container ID is omitted from the query string ("?" instead of // "?id=") so no ID leaks into the request. $_gtm_loader_query = $this->container_omit_id( $one_container ) ? '?\'+dl' : '?id=\'+i+dl'; /* * The four esc_js() calls below are string VALUES inside single-quoted JS * literals, so by RI-4's two-pile rule they belong in pile (b) - the pile * whose members normally get migrated to json_literal(). These four stay, * deliberately, and the reason is recorded here so the next re-derivation of * that ledger finds an answer rather than re-deciding: * * - json_literal() emits DOUBLE quotes. This block is Google's own container * snippet reproduced byte for byte (single quotes throughout, matching 1.x * and every copy of it Google publishes), and BE-1 keeps byte-exact tests * over it. Migrating would change the emitted snippet for every site to buy * nothing. * - Nothing is being reasoned about here on the basis of what the values * "happen to" contain - which is the filing #110 ruled out. Each of the four * passes an enforced allow-list on the way to this line, and none of those * allow-lists admits a quote: the container ID via ContainerRows::GTM_ID_PATTERN * in header_begin() before this method is called, the domain via * FILTER_VALIDATE_DOMAIN in container_domain(), the path via * ContainerRows::PATH_PATTERN in container_path(), and the data layer name via * ContainerRows::datalayer_name() at the reader. esc_js() is therefore a * provable no-op on all four, and the allow-lists are the actual control. * * If any of those four allow-lists is ever widened to admit a quote, this * block has to move to json_literal() and the byte-exact tests updated with it. */ return ' ' . $this->script_tag->opening_tag() . ' (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({\'gtm.start\': new Date().getTime(),event:\'gtm.js\'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!=\'dataLayer\'?\'&l=\'+l:\'\';j.async=true;j.src= \'//' . esc_js( $this->container_domain( $one_container ) ) . '/' . esc_js( $this->container_path( $one_container ) ) . $_gtm_loader_query . ( '' !== $_gtm_env ? "+'" . $_gtm_env . "'" : '' ) . ';f.parentNode.insertBefore(j,f); })(window,document,\'script\',\'' . esc_js( $datalayer_name ) . '\',\'' . esc_js( $one_gtm_id ) . '\'); '; } /** * Returns a HTML code that includes the noscript/iframe part of the Google Tag Manager container. * Can be used to manually place the snippet next to the opening body tag if the installed template * does not support the wp_body_open hook. * * @return string The HTML code that includes the noscript/iframe part of the GTM container code. */ public function get_tag(): string { $no_console_log = (bool) $this->options->get( GTM4WP_OPTION_NOCONSOLELOG ); $containers = $this->containers(); $excluded_role = $this->excluded_user_role(); $_gtm_tag = ' '; if ( GTM4WP_PLACEMENT_OFF === $this->options->get( GTM4WP_OPTION_GTM_PLACEMENT ) ) { // Placement OFF means the container code (both the head loader and // this noscript iframe) is never auto-emitted; only the data layer // stays active. Mark the code as "written" regardless of the // console-log setting so the iframe block below is always skipped - // otherwise, with console logging disabled, the iframe would still // be output despite the OFF placement. $GLOBALS['gtm4wp_container_code_written'] = true; if ( ! $no_console_log ) { $_gtm_tag .= ' ' . $this->script_tag->opening_tag() . ' console.warn && console.warn("[GTM4WP] Google Tag Manager container code placement set to OFF !!!"); console.warn && console.warn("[GTM4WP] Data layer codes are active but GTM container must be loaded using custom coding !!!"); '; } } elseif ( ! $this->should_output_container() ) { // Kill switch (production-only option / gtm4wp_output_container // filter): suppress the noscript iframe exactly like placement OFF - // mark the code as "written" so the iframe block below is skipped // while the data layer stays active. $GLOBALS['gtm4wp_container_code_written'] = true; if ( ! $no_console_log ) { $_gtm_tag .= $this->container_suppressed_warning(); } } elseif ( '' !== $excluded_role ) { // The user-role exclusion suppresses the container loader in // header_begin(); this iframe is the same container by another route, // so it has to go with it. It used to be emitted regardless, which // meant an excluded user still loaded the container (and was counted // in the reports) whenever JavaScript was unavailable - the exact // case the noscript iframe exists for. Mark the code as "written" so // the iframe block below is skipped, exactly like placement OFF and // the kill switch above; the data layer stays active. $GLOBALS['gtm4wp_container_code_written'] = true; if ( ! $no_console_log ) { $_gtm_tag .= $this->disabled_role_warning( $excluded_role ); } } if ( ( array() !== $containers ) && ( ! ( $GLOBALS['gtm4wp_container_code_written'] ?? false ) ) ) { foreach ( $containers as $one_container ) { $one_gtm_id = (string) ( $one_container[ ContainerRows::COLUMN_ID ] ?? '' ); // esc_attr() per PART below, at the point of injection (#225). Each part // is already allow-list validated - the id by the pattern one line down, // the domain by container_domain()'s FILTER_VALIDATE_DOMAIN fallback, the // environment values inside container_environment() - so esc_attr() is // the identity function on everything the validators pass today and // exists for the day a pattern is widened. NOT esc_attr()/esc_url() over // the whole src: the environment fragment joins with raw '&' on purpose // (wp_kses encodes it to & at output, the 1.x byte form), and // gtm4wp_get_the_gtm_tag() returns this string as public 1.x API - // pre-encoding it would change those bytes and hand third-party // consumers an already-escaped value to double-escape (RI-4). if ( preg_match( ContainerRows::GTM_ID_PATTERN, $one_gtm_id ) ) { $_gtm_tag .= ' '; } } $_gtm_tag .= ' '; $GLOBALS['gtm4wp_container_code_written'] = true; } return $_gtm_tag; } /** * Outputs a HTML code that includes the noscript/iframe part of the Google Tag Manager container. * * @return void */ public function the_tag(): void { // The iframe's inline style needs `display` and `visibility`. Whether // safecss_filter_attr() strips those depends on the WordPress version: // core added `display` to the safe_style_css default list in 7.0 and // `visibility` in 7.1, so on 6.3-6.9 both are stripped and on 7.0 // `visibility` still is. While the floor is 6.3 this filter is // load-bearing, not belt-and-braces - drop it only in the change that // raises the floor past 7.1, never as a "core allows it now" cleanup. // // Widen the CSS allow-list around THIS wp_kses() call only, then put it back: // safe_style_css is a global WordPress control, so a filter left // registered would relax it for every other wp_kses()/wp_kses_post() call // in the same request - including wp_filter_post_kses on content saved by // users without unfiltered_html, and a REST save is not is_admin(). One // line of our own markup is not a reason to loosen the rule site-wide. add_filter( 'safe_style_css', array( self::class, 'allow_iframe_hiding_styles' ) ); try { // print_markup_block(), not a bare wp_kses(): this block mixes the noscript // iframe (whose src must keep its & entity form) with the console // warning