t( $_GET[ self::VALIDATE_QUERY_VAR ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return false; } $validate_key = wp_unslash( $_GET[ self::VALIDATE_QUERY_VAR ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( ! hash_equals( self::get_amp_validate_nonce(), $validate_key ) ) { return new WP_Error( 'http_request_failed', __( 'Nonce authentication failed.', 'amp' ) ); } return true; } /** * Get response data for a validate request. * * @see AMP_Content_Sanitizer::sanitize_document() * * @param array $sanitization_results { * Results of sanitizing a document, as returned by AMP_Content_Sanitizer::sanitize_document(). * * @type array $scripts Scripts. * @type array $stylesheets Stylesheets. * @type AMP_Base_Sanitizer[] $sanitizers Sanitizers. * } * @return array Validate response data. */ public static function get_validate_response_data( $sanitization_results ) { $data = [ 'results' => self::$validation_results, 'queried_object' => null, 'url' => amp_get_current_url(), ]; $queried_object = get_queried_object(); if ( $queried_object ) { $data['queried_object'] = []; $queried_object_id = get_queried_object_id(); if ( $queried_object_id ) { $data['queried_object']['id'] = $queried_object_id; } if ( $queried_object instanceof WP_Post ) { $data['queried_object']['type'] = 'post'; } elseif ( $queried_object instanceof WP_Term ) { $data['queried_object']['type'] = 'term'; } elseif ( $queried_object instanceof WP_User ) { $data['queried_object']['type'] = 'user'; } elseif ( $queried_object instanceof WP_Post_Type ) { $data['queried_object']['type'] = 'post_type'; } } /** * Sanitizers * * @var AMP_Base_Sanitizer[] $sanitizers */ $sanitizers = $sanitization_results['sanitizers']; foreach ( $sanitizers as $class_name => $sanitizer ) { $sanitizer_data = $sanitizer->get_validate_response_data(); $conflicting_keys = array_intersect( array_keys( $sanitizer_data ), array_keys( $data ) ); if ( ! empty( $conflicting_keys ) ) { _doing_it_wrong( esc_html( "$class_name::get_validate_response_data" ), esc_html( 'Method is returning array with conflicting keys: ' . implode( ', ', $conflicting_keys ) ), '1.5' ); } else { $data = array_merge( $data, $sanitizer_data ); } } return $data; } /** * Remove source stack comments which appear inside of script and style tags. * * HTML comments that appear inside of script and style elements get parsed as text content. AMP does not allow * such HTML comments to appear inside of CDATA, resulting in validation errors to be emitted when validating a * page that happens to have source stack comments output when generating JSON data (e.g. All in One SEO). * Additionally, when source stack comments are output inside of style elements the result can either be CSS * parse errors or incorrect stylesheet sizes being reported due to the presence of the source stack comments. * So to prevent these issues from occurring, the source stack comments need to be removed from the document prior * to sanitizing. * * @since 1.5 * * @param Document $dom Document. */ public static function remove_illegal_source_stack_comments( Document $dom ) { /** * Script element. * * @var DOMText $text */ foreach ( $dom->xpath->query( '//text()[ contains( ., "#s', '', $text->nodeValue ); } } /** * Finalize validation. * * @see AMP_Validation_Manager::add_admin_bar_menu_items() * * @param Document $dom Document. * @return bool Whether the document should be displayed to the user. */ public static function finalize_validation( Document $dom ) { $total_count = 0; $kept_count = 0; $unreviewed_count = 0; foreach ( self::$validation_results as $validation_result ) { $sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $validation_result['error'] ); if ( ! ( (int) $sanitization['status'] & AMP_Validation_Error_Taxonomy::ACCEPTED_VALIDATION_ERROR_BIT_MASK ) ) { $kept_count++; } if ( ! ( (int) $sanitization['status'] & AMP_Validation_Error_Taxonomy::ACKNOWLEDGED_VALIDATION_ERROR_BIT_MASK ) ) { $unreviewed_count++; } $total_count++; } /* * Override AMP status in admin bar set in \AMP_Validation_Manager::add_admin_bar_menu_items() * when there are validation errors which have not been explicitly accepted. */ if ( is_admin_bar_showing() && self::$amp_admin_bar_item_added && $total_count > 0 ) { self::update_admin_bar_item( $dom, $total_count, $kept_count, $unreviewed_count ); } // If no invalid markup is kept, then the page should definitely be displayed to the user. if ( 0 === $kept_count ) { return true; } // When overrides are present, go ahead and display to the user. if ( ! empty( self::$validation_error_status_overrides ) ) { return true; } /* * In AMP-first, strip html@amp attribute to prevent GSC from complaining about a validation error * already surfaced inside of WordPress. This is intended to not serve dirty AMP, but rather a * non-AMP document (intentionally not valid AMP) that contains the AMP runtime and AMP components. * * Otherwise, if in Paired AMP then redirect to the non-AMP version if the current user isn't an user who * can manage validation error statuses (access developer tools) and change the AMP options for the template * mode. Such users should be able to see kept invalid markup on the AMP page even though it is invalid. */ if ( amp_is_canonical() ) { $dom->documentElement->removeAttribute( Attribute::AMP ); $dom->documentElement->removeAttribute( Attribute::AMP_EMOJI ); $dom->documentElement->removeAttribute( Attribute::AMP_EMOJI_ALT ); /* * Make sure that document.write() is disabled to prevent dynamically-added content (such as added * via amp-live-list) from wiping out the page by introducing any scripts that call this function. */ $script = $dom->createElement( Tag::SCRIPT ); $script->appendChild( $dom->createTextNode( 'document.addEventListener( "DOMContentLoaded", function() { document.write = function( text ) { throw new Error( "[AMP-WP] Prevented document.write() call with: " + text ); }; } );' ) ); $dom->head->appendChild( $script ); return true; } // Otherwise, since it is in a paired mode, only allow showing the dirty AMP page if the user is authorized. // If not, normally the result is redirection to the non-AMP version. return self::has_cap() || is_customize_preview(); } /** * Override AMP status in admin bar set in \AMP_Validation_Manager::add_admin_bar_menu_items() * when there are validation errors which have not been explicitly accepted. * * @param Document $dom Document. * @param int $total_count Total count of validation errors (more than 0). * @param int $kept_count Count of validation errors with invalid markup kept. * @param int $unreviewed_count Count of unreviewed validation errors. */ private static function update_admin_bar_item( Document $dom, $total_count, $kept_count, $unreviewed_count ) { $parent_menu_item = $dom->getElementById( 'wp-admin-bar-amp' ); if ( ! $parent_menu_item instanceof DOMElement ) { return; } $parent_menu_link = $dom->xpath->query( './a[ @href ]', $parent_menu_item )->item( 0 ); $admin_bar_icon = $dom->xpath->query( './span[ @id = "amp-admin-bar-item-status-icon" ]', $parent_menu_link )->item( 0 ); $validate_link = $dom->xpath->query( './/li[ @id = "wp-admin-bar-amp-validity" ]/a[ @href ]', $parent_menu_item )->item( 0 ); if ( ! $parent_menu_link instanceof DOMElement || ! $admin_bar_icon instanceof DOMElement || ! $validate_link instanceof DOMElement ) { return; } /* * When in Paired AMP, non-administrators accessing the AMP version will get redirected to the non-AMP version * if there are is kept invalid markup. In Paired AMP, the AMP plugin never intends to advertise the availability * of dirty AMP pages. However, in AMP-first (Standard mode), there is no non-AMP version to redirect to, so * kept invalid markup does not cause redirection but rather the `amp` attribute is removed from the AMP page * to serve an intentionally invalid AMP page with the AMP runtime loaded which is exempted from AMP validation * (and excluded from being indexed as an AMP page). So this is why the first conditional will only show the * error icon for kept markup when _not_ AMP-first. This will only be displayed to administrators who are directly * accessing the AMP version. Otherwise, if there is no kept invalid markup _or_ it is AMP-first, then the AMP * admin bar item will be updated to show if there are any unreviewed validation errors (regardless of whether * they are kept or removed). */ if ( $kept_count > 0 && ! amp_is_canonical() ) { $admin_bar_icon->setAttribute( 'class', 'ab-icon amp-icon ' . Icon::INVALID ); } elseif ( $unreviewed_count > 0 || $kept_count > 0 ) { $admin_bar_icon->setAttribute( 'class', 'ab-icon amp-icon ' . Icon::WARNING ); } // Update the text of the link to reflect the status of the validation error(s). $items = []; if ( $unreviewed_count > 0 ) { if ( $unreviewed_count === $total_count ) { /* translators: text is describing validation issue(s) */ $items[] = _n( 'unreviewed', 'all unreviewed', $unreviewed_count, 'amp' ); } else { $items[] = sprintf( /* translators: %s the total count of unreviewed validation errors */ _n( '%s unreviewed', '%s unreviewed', $unreviewed_count, 'amp' ), number_format_i18n( $unreviewed_count ) ); } } if ( $kept_count > 0 ) { if ( $kept_count === $total_count ) { /* translators: text is describing validation issue(s) */ $items[] = _n( 'kept', 'all kept', $kept_count, 'amp' ); } else { $items[] = sprintf( /* translators: %s the total count of unreviewed validation errors */ _n( '%s kept', '%s kept', $kept_count, 'amp' ), number_format_i18n( $kept_count ) ); } } if ( empty( $items ) ) { /* translators: text is describing validation issue(s) */ $items[] = _n( 'reviewed', 'all reviewed', $total_count, 'amp' ); } $text = sprintf( /* translators: %s is total count of validation errors */ _n( '%s issue:', '%s issues:', $total_count, 'amp' ), number_format_i18n( $total_count ) ); $text .= ' ' . implode( ', ', $items ); $validate_link->appendChild( $dom->createTextNode( ' ' ) ); $small = $dom->createElement( 'small' ); $small->setAttribute( 'style', 'font-size: smaller' ); $small->appendChild( $dom->createTextNode( sprintf( '(%s)', $text ) ) ); $validate_link->appendChild( $small ); } /** * Adds the validation callback if front-end validation is needed. * * @param array $sanitizers The AMP sanitizers. * @return array $sanitizers The filtered AMP sanitizers. */ public static function filter_sanitizer_args( $sanitizers ) { foreach ( $sanitizers as &$args ) { $args['validation_error_callback'] = __CLASS__ . '::add_validation_error'; } if ( isset( $sanitizers['AMP_Style_Sanitizer'] ) ) { $sanitizers['AMP_Style_Sanitizer']['should_locate_sources'] = self::$is_validate_request; $css_validation_errors = []; foreach ( self::$validation_error_status_overrides as $slug => $status ) { $term = AMP_Validation_Error_Taxonomy::get_term( $slug ); if ( ! $term ) { continue; } $validation_error = json_decode( $term->description, true ); $is_css_validation_error = ( is_array( $validation_error ) && isset( $validation_error['code'] ) && in_array( $validation_error['code'], AMP_Style_Sanitizer::get_css_parser_validation_error_codes(), true ) ); if ( $is_css_validation_error ) { $css_validation_errors[ $slug ] = $status; } } if ( ! empty( $css_validation_errors ) ) { $sanitizers['AMP_Style_Sanitizer']['parsed_cache_variant'] = md5( wp_json_encode( $css_validation_errors ) ); } } return $sanitizers; } /** * Validates the latest published post. * * @return array|WP_Error The validation errors, or WP_Error. */ public static function validate_after_plugin_activation() { $url = amp_admin_get_preview_permalink(); if ( ! $url ) { return new WP_Error( 'no_published_post_url_available' ); } $validity = self::validate_url_and_store( $url ); if ( is_wp_error( $validity ) ) { return $validity; } $validation_errors = wp_list_pluck( $validity['results'], 'error' ); if ( is_array( $validity ) && count( $validation_errors ) > 0 ) { // @todo This should only warn when there are unaccepted validation errors. set_transient( self::PLUGIN_ACTIVATION_VALIDATION_ERRORS_TRANSIENT_KEY, $validation_errors, 60 ); } else { delete_transient( self::PLUGIN_ACTIVATION_VALIDATION_ERRORS_TRANSIENT_KEY ); } return $validation_errors; } /** * Validates a given URL. * * The validation errors will be stored in the validation status custom post type, * as well as in a transient. * * @param string $url The URL to validate. This need not include the amp query var. * @return WP_Error|array { * Response. * * @type array $results Validation results, where each nested array contains an error key and sanitized key. * @type string $url Final URL that was checked or redirected to. * @type array $queried_object Queried object, including keys for 'type' and 'id'. * @type array $stylesheets Stylesheet data. * @type string $php_fatal_error PHP fatal error which occurred during validation. * } */ public static function validate_url( $url ) { if ( ! amp_is_canonical() && ! amp_has_paired_endpoint( $url ) ) { $url = amp_add_paired_endpoint( $url ); } $added_query_vars = [ self::VALIDATE_QUERY_VAR => self::get_amp_validate_nonce(), self::CACHE_BUST_QUERY_VAR => wp_rand(), ]; $validation_url = add_query_arg( $added_query_vars, $url ); $r = null; /** This filter is documented in wp-includes/class-http.php */ $allowed_redirects = apply_filters( 'http_request_redirection_count', 5 ); for ( $redirect_count = 0; $redirect_count < $allowed_redirects; $redirect_count++ ) { $r = wp_remote_get( $validation_url, [ 'cookies' => wp_unslash( $_COOKIE ), // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE -- Pass along cookies so private pages and drafts can be accessed. 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Increase from default of 5 to give extra time for the plugin to identify the sources for any given validation errors. /** This filter is documented in wp-includes/class-wp-http-streams.php */ 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 'redirection' => 0, // Because we're in a loop for redirection. 'headers' => [ 'Cache-Control' => 'no-cache', ], ] ); // If the response is not a redirect, then break since $r is all we need. $response_code = wp_remote_retrieve_response_code( $r ); $location_header = wp_remote_retrieve_header( $r, 'Location' ); $is_redirect = ( $response_code && $response_code > 300 && $response_code < 400 && $location_header ); if ( ! $is_redirect ) { break; } // Ensure absolute URL. if ( '/' === substr( $location_header, 0, 1 ) ) { $location_header = preg_replace( '#(^https?://[^/]+)/.*#', '$1', home_url( '/' ) ) . $location_header; } // Block redirecting to a different host. $location_header = wp_validate_redirect( $location_header ); if ( ! $location_header ) { break; } $validation_url = add_query_arg( $added_query_vars, $location_header ); } if ( is_wp_error( $r ) ) { return $r; } $response = trim( wp_remote_retrieve_body( $r ) ); if ( wp_remote_retrieve_response_code( $r ) >= 400 ) { $data = json_decode( $response, true ); return new WP_Error( is_array( $data ) && isset( $data['code'] ) ? $data['code'] : wp_remote_retrieve_response_code( $r ), is_array( $data ) && isset( $data['message'] ) ? $data['message'] : wp_remote_retrieve_response_message( $r ) ); } if ( wp_remote_retrieve_response_code( $r ) >= 300 ) { return new WP_Error( 'http_request_failed', __( 'Too many redirects.', 'amp' ) ); } $url = remove_query_arg( array_keys( $added_query_vars ), $validation_url ); // Strip byte order mark (BOM). while ( "\xEF\xBB\xBF" === substr( $response, 0, 3 ) ) { $response = substr( $response, 3 ); } // Strip any leading whitespace. $response = ltrim( $response ); // Strip HTML comments that may have been injected at the end of the response (e.g. by a caching plugin). while ( ! empty( $response ) ) { $response = rtrim( $response ); $length = strlen( $response ); if ( $length < 3 || '-' !== $response[ $length - 3 ] || '-' !== $response[ $length - 2 ] || '>' !== $response[ $length - 1 ] ) { break; } $start = strrpos( $response, '