Short-circuit validation requests that are unauthorized. if ( $should_validate_response instanceof WP_Error ) { wp_send_json( [ 'code' => $should_validate_response->get_error_code(), 'message' => $should_validate_response->get_error_message(), ], 401 ); } } } /** * Add hooks for doing determining sources for validation errors during preprocessing/sanitizing. */ public static function add_validation_error_sourcing() { add_action( 'wp', [ __CLASS__, 'wrap_widget_callbacks' ] ); add_action( 'all', [ __CLASS__, 'wrap_hook_callbacks' ] ); $wrapped_filters = [ 'the_content', 'the_excerpt' ]; foreach ( $wrapped_filters as $wrapped_filter ) { add_filter( $wrapped_filter, [ __CLASS__, 'decorate_filter_source' ], PHP_INT_MAX ); } add_filter( 'do_shortcode_tag', [ __CLASS__, 'decorate_shortcode_source' ], PHP_INT_MAX, 2 ); add_filter( 'embed_oembed_html', [ __CLASS__, 'decorate_embed_source' ], PHP_INT_MAX, 3 ); add_filter( 'the_content', [ __CLASS__, 'add_block_source_comments' ], 8 ); // The do_blocks() function runs at priority 9. } /** * Handle save_post action to queue re-validation of the post on the frontend. * * This is intended to only apply to post edits made in the classic editor. * * @deprecated In 2.1 the classic editor block validation was removed. * @codeCoverageIgnore */ public static function handle_save_post_prompting_validation() { _deprecated_function( __METHOD__, '2.1' ); } /** * Validate the posts pending frontend validation. * * @see AMP_Validation_Manager::handle_save_post_prompting_validation() * * @deprecated In 2.1 the classic editor block validation was removed. * @codeCoverageIgnore */ public static function validate_queued_posts_on_frontend() { _deprecated_function( __METHOD__, '2.1' ); } /** * Map the amp_validate meta capability to the primitive manage_options capability. * * Using a meta capability allows a site to customize which users get access to perform validation. * * @param string[] $caps Array of the user's capabilities. * @param string $cap Capability name. * @return string[] Filtered primitive capabilities. */ public static function map_meta_cap( $caps, $cap ) { if ( self::VALIDATE_CAPABILITY === $cap ) { // Note that $caps most likely only contains a single item anyway, but only swapping out the one meta // capability with the primitive capability allows a site to add additional required capabilities. $position = array_search( $cap, $caps, true ); if ( false !== $position ) { $caps[ $position ] = 'manage_options'; } } return $caps; } /** * Whether the user has the required capability to validate. * * Checks for permissions before validating. * * @param int|WP_User|null $user User to check for the capability. If null, the current user is used. * @return boolean $has_cap Whether the current user has the capability. */ public static function has_cap( $user = null ) { if ( null === $user ) { $user = wp_get_current_user(); } return user_can( $user, self::VALIDATE_CAPABILITY ); } /** * Add validation error. * * @param array $error Error info, especially code. * @param array $data Additional data, including the node. * * @return bool Whether the validation error should result in sanitization. */ public static function add_validation_error( array $error, array $data = [] ) { $node = null; $sources = null; if ( isset( $data['node'] ) && $data['node'] instanceof DOMNode ) { $node = $data['node']; } if ( self::$is_validate_request ) { if ( ! empty( $error['sources'] ) ) { $sources = $error['sources']; } elseif ( $node ) { $sources = self::locate_sources( $node ); } } unset( $error['sources'] ); if ( ! isset( $error['code'] ) ) { $error['code'] = 'unknown'; } /** * Filters the validation error array. * * This allows plugins to add amend additional properties which can help with * more accurately identifying a validation error beyond the name of the parent * node and the element's attributes. The $sources are also omitted because * these are only available during an explicit validation request and so they * are not suitable for plugins to vary sanitization by. If looking to force a * validation error to be ignored, use the 'amp_validation_error_sanitized' * filter instead of attempting to return an empty value with this filter (as * that is not supported). * * @since 1.0 * * @param array $error Validation error to be printed. * @param array $context { * Context data for validation error sanitization. * * @type DOMNode $node Node for which the validation error is being reported. May be null. * } */ $error = apply_filters( 'amp_validation_error', $error, compact( 'node' ) ); $sanitization = AMP_Validation_Error_Taxonomy::get_validation_error_sanitization( $error ); $sanitized = ( AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS === $sanitization['status'] || AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_ACCEPTED_STATUS === $sanitization['status'] ); /* * Ignore validation errors which are forcibly sanitized by filter. This includes errors accepted via * AMP_Validation_Error_Taxonomy::accept_validation_errors(), such as the acceptable_errors in core themes. * This was introduced in to prevent forcibly-sanitized * validation errors from being reported, to avoid noise and wasted storage. It was inadvertently * reverted in de7b04b but then restored as part of . */ if ( $sanitized && 'with_filter' === $sanitization['forced'] ) { return true; } // Add sources back into the $error for referencing later. @todo It may be cleaner to store sources separately to avoid having to re-remove later during storage. $error = array_merge( $error, compact( 'sources' ) ); self::$validation_results[] = compact( 'error', 'sanitized' ); return $sanitized; } /** * Reset the stored removed nodes and attributes. * * After testing if the markup is valid, * these static values will remain. * So reset them in case another test is needed. * * @return void */ public static function reset_validation_results() { self::$validation_results = []; self::$enqueued_style_sources = []; self::$enqueued_script_sources = []; self::$extra_script_sources = []; self::$extra_style_sources = []; } /** * Checks the AMP validity of the post content. * * If it's not valid AMP, it displays an error message above the 'Classic' editor. * * This is essentially a PHP implementation of ampBlockValidation.handleValidationErrorsStateChange() in JS. * * @deprecated In 2.1 the classic editor block validation was removed. * @codeCoverageIgnore * @return void */ public static function print_edit_form_validation_status() { _deprecated_function( __METHOD__, '2.1' ); } /** * Get source start comment. * * @param array $source Source data. * @param bool $is_start Whether the comment is the start or end. * @return string HTML Comment. */ public static function get_source_comment( array $source, $is_start = true ) { unset( $source['reflection'] ); return sprintf( '', $is_start ? '' : '/', str_replace( '--', '', wp_json_encode( $source ) ) ); } /** * Parse source comment. * * @param DOMComment $comment Comment. * @return array|null Parsed source or null if not a source comment. */ public static function parse_source_comment( DOMComment $comment ) { if ( ! preg_match( '#^\s*(?P/)?amp-source-stack\s+(?P{.+})\s*$#s', $comment->nodeValue, $matches ) ) { return null; } $source = json_decode( $matches['args'], true ); $closing = ! empty( $matches['closing'] ); return compact( 'source', 'closing' ); } /** * Recursively determine if a given dependency depends on another. * * @since 1.3 * * @param WP_Dependencies $dependencies Dependencies. * @param string $current_handle Current handle. * @param string $dependency_handle Dependency handle. * @return bool Whether the current handle is a dependency of the dependency handle. */ protected static function has_dependency( WP_Dependencies $dependencies, $current_handle, $dependency_handle ) { if ( $current_handle === $dependency_handle ) { return true; } if ( ! isset( $dependencies->registered[ $current_handle ] ) ) { return false; } foreach ( $dependencies->registered[ $current_handle ]->deps as $handle ) { if ( self::has_dependency( $dependencies, $handle, $dependency_handle ) ) { return true; } } return false; } /** * Determine if a script element matches a given script handle. * * @param DOMElement $element Element. * @param string $script_handle Script handle. * @return bool */ protected static function is_matching_script( DOMElement $element, $script_handle ) { if ( ! isset( wp_scripts()->registered[ $script_handle ] ) ) { return false; } $script_dependency = wp_scripts()->registered[ $script_handle ]; if ( empty( $script_dependency->src ) ) { return false; } // Script src attribute is haystack because includes protocol and may include query args (like ver). return false !== strpos( $element->getAttribute( 'src' ), preg_replace( '#^https?:(?=//)#', '', $script_dependency->src ) ); } /** * Walk back tree to find the open sources. * * @todo This method and others for sourcing could be moved to a separate class. * * @param DOMNode $node Node to look for. * @return array[][] { * The data of the removed sources (theme, plugin, or mu-plugin). * * @type string $name The name of the source. * @type string $type The type of the source. * } */ public static function locate_sources( DOMNode $node ) { $dom = Document::fromNode( $node ); $comments = $dom->xpath->query( 'preceding::comment()[ starts-with( ., "amp-source-stack" ) or starts-with( ., "/amp-source-stack" ) ]', $node ); $sources = []; $matches = []; foreach ( $comments as $comment ) { $parsed_comment = self::parse_source_comment( $comment ); if ( ! $parsed_comment ) { continue; } if ( $parsed_comment['closing'] ) { array_pop( $sources ); } else { $sources[] = $parsed_comment['source']; } } $is_enqueued_link = ( $node instanceof DOMElement && 'link' === $node->nodeName && preg_match( '/(?P.+)-css$/', (string) $node->getAttribute( 'id' ), $matches ) && wp_styles()->query( $matches['handle'] ) ); if ( $is_enqueued_link ) { // Directly enqueued stylesheet. if ( isset( self::$enqueued_style_sources[ $matches['handle'] ] ) ) { $sources = array_merge( self::$enqueued_style_sources[ $matches['handle'] ], $sources ); } // Stylesheet added as a dependency. foreach ( wp_styles()->done as $style_handle ) { if ( $matches['handle'] !== $style_handle ) { continue; } foreach ( self::$enqueued_style_sources as $enqueued_style_sources_handle => $enqueued_style_sources ) { if ( $enqueued_style_sources_handle !== $style_handle && wp_styles()->query( $enqueued_style_sources_handle, 'done' ) && self::has_dependency( wp_styles(), $enqueued_style_sources_handle, $style_handle ) ) { $sources = array_merge( array_map( static function ( $enqueued_style_source ) use ( $style_handle ) { $enqueued_style_source['dependency_handle'] = $style_handle; return $enqueued_style_source; }, $enqueued_style_sources ), $sources ); } } } } $is_inline_style = ( $node instanceof DOMElement && 'style' === $node->nodeName && $node->firstChild instanceof DOMText && $node->hasAttribute( 'id' ) && preg_match( '/^(?P.+)-inline-css$/', $node->getAttribute( 'id' ), $matches ) && wp_styles()->query( $matches['handle'] ) && isset( self::$extra_style_sources[ $matches['handle'] ] ) ); if ( $is_inline_style ) { $text = $node->textContent; foreach ( self::$extra_style_sources[ $matches['handle'] ] as $css => $extra_sources ) { if ( false !== strpos( $text, $css ) ) { $sources = array_merge( $sources, $extra_sources ); } } } if ( $node instanceof DOMElement && 'script' === $node->nodeName ) { $enqueued_script_handles = array_intersect( wp_scripts()->done, array_keys( self::$enqueued_script_sources ) ); if ( $node->hasAttribute( 'src' ) ) { // External scripts, directly enqueued. foreach ( $enqueued_script_handles as $enqueued_script_handle ) { if ( ! self::is_matching_script( $node, $enqueued_script_handle ) ) { continue; } $sources = array_merge( self::$enqueued_script_sources[ $enqueued_script_handle ], $sources ); break; } // External scripts, added as a dependency. foreach ( wp_scripts()->done as $script_handle ) { if ( ! self::is_matching_script( $node, $script_handle ) ) { continue; } foreach ( self::$enqueued_script_sources as $enqueued_script_sources_handle => $enqueued_script_sources ) { if ( $enqueued_script_sources_handle !== $script_handle && wp_scripts()->query( $enqueued_script_sources_handle, 'done' ) && self::has_dependency( wp_scripts(), $enqueued_script_sources_handle, $script_handle ) ) { $sources = array_merge( array_map( static function ( $enqueued_script_source ) use ( $script_handle ) { $enqueued_script_source['dependency_handle'] = $script_handle; return $enqueued_script_source; }, $enqueued_script_sources ), $sources ); } } } } elseif ( $node->firstChild instanceof DOMText ) { $text = $node->textContent; // Identify the inline script sources. foreach ( self::$extra_script_sources as $extra_data => $extra_sources ) { if ( false !== strpos( $text, $extra_data ) ) { $sources = array_merge( $sources, $extra_sources ); } } } } $sources = array_values( array_unique( $sources, SORT_REGULAR ) ); return $sources; } /** * Add block source comments. * * @param string $content Content prior to blocks being processed. * @return string Content with source comments added. */ public static function add_block_source_comments( $content ) { self::$block_content_index = 0; $start_block_pattern = implode( '', [ '##s', ] ); return preg_replace_callback( $start_block_pattern, [ __CLASS__, 'handle_block_source_comment_replacement' ], $content ); } /** * Handle block source comment replacement. * * @see \AMP_Validation_Manager::add_block_source_comments() * * @param array $matches Matches. * * @return string Replaced. */ protected static function handle_block_source_comment_replacement( $matches ) { $replaced = $matches[0]; // Obtain source information for block. $source = [ 'block_name' => $matches['name'], 'post_id' => get_the_ID(), ]; if ( empty( $matches['closing'] ) ) { $source['block_content_index'] = self::$block_content_index; self::$block_content_index++; } // Make implicit core namespace explicit. $is_implicit_core_namespace = ( false === strpos( $source['block_name'], '/' ) ); $source['block_name'] = $is_implicit_core_namespace ? 'core/' . $source['block_name'] : $source['block_name']; if ( ! empty( $matches['attributes'] ) ) { $source['block_attrs'] = json_decode( $matches['attributes'] ); } $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $source['block_name'] ); if ( $block_type && $block_type->is_dynamic() ) { $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); $callback_source = $callback_reflection->get_source( $block_type->render_callback ); if ( $callback_source ) { $source = array_merge( $source, $callback_source ); } } if ( ! empty( $matches['closing'] ) ) { $replaced .= self::get_source_comment( $source, false ); } else { $replaced = self::get_source_comment( $source, true ) . $replaced; if ( ! empty( $matches['self_closing'] ) ) { unset( $source['block_content_index'] ); $replaced .= self::get_source_comment( $source, false ); } } return $replaced; } /** * Wrap callbacks for registered widgets to keep track of queued assets and the source for anything printed for validation. * * @return void * @global array $wp_registered_widgets */ public static function wrap_widget_callbacks() { global $wp_registered_widgets; $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); foreach ( $wp_registered_widgets as $widget_id => &$registered_widget ) { $source = $callback_reflection->get_source( $registered_widget['callback'] ); if ( ! $source ) { continue; } $source['widget_id'] = $widget_id; unset( $source['reflection'] ); // Omit from stored source. $function = $registered_widget['callback']; $accepted_args = 2; // For the $instance and $args arguments. $callback = compact( 'function', 'accepted_args', 'source' ); $registered_widget['callback'] = self::wrapped_callback( $callback ); } } /** * Wrap filter/action callback functions for a given hook. * * Wrapped callback functions are reset to their original functions after invocation. * This runs at the 'all' action. The shutdown hook is excluded. * * @global WP_Hook[] $wp_filter * @param string $hook Hook name for action or filter. * @return void */ public static function wrap_hook_callbacks( $hook ) { global $wp_filter; if ( ! isset( $wp_filter[ $hook ] ) || 'shutdown' === $hook ) { return; } $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); self::$current_hook_source_stack[ $hook ] = []; foreach ( $wp_filter[ $hook ]->callbacks as $priority => &$callbacks ) { foreach ( $callbacks as &$callback ) { $source = $callback_reflection->get_source( $callback['function'] ); if ( ! $source ) { continue; } // Skip considering ourselves. if ( 'AMP_Validation_Manager::add_block_source_comments' === $source['function'] ) { continue; } /** * Reflection. * * @var ReflectionFunction|ReflectionMethod $reflection */ $reflection = $source['reflection']; unset( $source['reflection'] ); // Omit from stored source. // Add hook to stack for decorate_filter_source to read from. self::$current_hook_source_stack[ $hook ][] = $source; /* * Wrapped callbacks cause PHP warnings when the wrapped function has arguments passed by reference. * We have a special case to support functions that have the first argument passed by reference, namely * wp_default_scripts() and wp_default_styles(). But other configurations are bypassed. */ $passed_by_ref = self::has_parameters_passed_by_reference( $reflection ); if ( $passed_by_ref > 1 ) { continue; } $source['hook'] = $hook; $source['priority'] = $priority; $original_function = $callback['function']; $wrapped_callback = self::wrapped_callback( array_merge( $callback, compact( 'priority', 'source' ) ) ); if ( 1 === $passed_by_ref ) { $callback['function'] = static function( &$first, ...$other_args ) use ( &$callback, $wrapped_callback, $original_function ) { $callback['function'] = $original_function; // Restore original. return $wrapped_callback->invoke_with_first_ref_arg( $first, ...$other_args ); }; } else { $callback['function'] = static function( ...$args ) use ( &$callback, $wrapped_callback, $original_function ) { $callback['function'] = $original_function; // Restore original. return $wrapped_callback( ...$args ); }; } } } } /** * Determine whether the given reflection method/function has params passed by reference. * * @since 0.7 * @param ReflectionFunction|ReflectionMethod $reflection Reflection. * @return int Whether there are parameters passed by reference, where 0 means none were passed, 1 means the first was passed, and 2 means some other configuration. */ protected static function has_parameters_passed_by_reference( $reflection ) { $status = 0; foreach ( $reflection->getParameters() as $i => $parameter ) { if ( $parameter->isPassedByReference() ) { if ( 0 === $i ) { $status = 1; } else { $status = 2; break; } } } return $status; } /** * Filters the output created by a shortcode callback. * * @since 0.7 * * @param string $output Shortcode output. * @param string $tag Shortcode name. * @return string Output. * @global array $shortcode_tags */ public static function decorate_shortcode_source( $output, $tag ) { global $shortcode_tags; if ( ! isset( $shortcode_tags[ $tag ] ) ) { return $output; } $callback_reflection = Services::get( 'dev_tools.callback_reflection' ); $source = $callback_reflection->get_source( $shortcode_tags[ $tag ] ); if ( empty( $source ) ) { return $output; } $source['shortcode'] = $tag; $output = implode( '', [ self::get_source_comment( $source, true ), $output, self::get_source_comment( $source, false ), ] ); return $output; } /** * Filters the output created by embeds. * * @since 1.0 * * @param string $output Embed output. * @param string $url URL. * @param array $attr Attributes. * @return string Output. */ public static function decorate_embed_source( $output, $url, $attr ) { $source = [ 'embed' => $url, 'attr' => $attr, ]; return implode( '', [ self::get_source_comment( $source, true ), trim( $output ), self::get_source_comment( $source, false ), ] ); } /** * Wraps output of a filter to add source stack comments. * * @todo Duplicate with AMP_Validation_Manager::wrap_buffer_with_source_comments()? * @param string $value Value. * @return string Value wrapped in source comments. */ public static function decorate_filter_source( $value ) { // Abort if the output is not a string and it doesn't contain any HTML tags. if ( ! is_string( $value ) || ! preg_match( '/<.+?>/s', $value ) ) { return $value; } $post = get_post(); $source = [ 'hook' => current_filter(), 'filter' => true, ]; if ( $post ) { $source['post_id'] = $post->ID; $source['post_type'] = $post->post_type; } if ( isset( self::$current_hook_source_stack[ current_filter() ] ) ) { $sources = self::$current_hook_source_stack[ current_filter() ]; array_pop( $sources ); // Remove self. $source['sources'] = $sources; } return implode( '', [ self::get_source_comment( $source, true ), $value, self::get_source_comment( $source, false ), ] ); } /** * Gets the plugin or theme of the callback, if one exists. * * @deprecated 2.0.2 Use \AmpProject\AmpWP\DevTools\CallbackReflection::get_source(). * @codeCoverageIgnore * * @param string|array|callable $callback The callback for which to get the plugin. * @return array|null { * The source data. * * @type string $type Source type (core, plugin, mu-plugin, or theme). * @type string $name Source name. * @type string $file Relative file path based on the type. * @type string $function Normalized function name. * @type ReflectionMethod|ReflectionFunction $reflection Reflection. * } */ public static function get_source( $callback ) { _deprecated_function( __METHOD__, '2.0.2', '\AmpProject\AmpWP\DevTools\CallbackReflection::get_source' ); return Services::get( 'dev_tools.callback_reflection' ) ->get_source( $callback ); } /** * Check whether or not output buffering is currently possible. * * This is to guard against a fatal error: "ob_start(): Cannot use output buffering in output buffering display handlers". * * @return bool Whether output buffering is allowed. */ public static function can_output_buffer() { // Output buffering for validation can only be done while overall output buffering is being done for the response. if ( ! AMP_Theme_Support::is_output_buffering() ) { return false; } // Abort when in shutdown since output has finished, when we're likely in the overall output buffering display handler. if ( did_action( 'shutdown' ) ) { return false; } // Check if any functions in call stack are output buffering display handlers. $called_functions = []; if ( defined( 'DEBUG_BACKTRACE_IGNORE_ARGS' ) ) { $arg = DEBUG_BACKTRACE_IGNORE_ARGS; // phpcs:ignore PHPCompatibility.Constants.NewConstants.debug_backtrace_ignore_argsFound } else { $arg = false; } $backtrace = debug_backtrace( $arg ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Only way to find out if we are in a buffering display handler. foreach ( $backtrace as $call_stack ) { if ( '{closure}' === $call_stack['function'] ) { $called_functions[] = 'Closure::__invoke'; } elseif ( isset( $call_stack['class'] ) ) { $called_functions[] = sprintf( '%s::%s', $call_stack['class'], $call_stack['function'] ); } else { $called_functions[] = $call_stack['function']; } } return 0 === count( array_intersect( ob_list_handlers(), $called_functions ) ); } /** * Wraps a callback in comments if it outputs markup. * * If the sanitizer removes markup, * this indicates which plugin it was from. * The call_user_func_array() logic is mainly copied from WP_Hook:apply_filters(). * * @param array $callback { * The callback data. * * @type callable $function * @type int $accepted_args * @type array $source * } * @return AMP_Validation_Callback_Wrapper $wrapped_callback The callback, wrapped in comments. */ public static function wrapped_callback( $callback ) { return new AMP_Validation_Callback_Wrapper( $callback ); } /** * Wrap output buffer with source comments. * * A key reason for why this is a method and not a closure is so that * the can_output_buffer method will be able to identify it by name. * * @since 0.7 * @todo Is duplicate of \AMP_Validation_Manager::decorate_filter_source()? * * @param string $output Output buffer. * @return string Output buffer conditionally wrapped with source comments. */ public static function wrap_buffer_with_source_comments( $output ) { if ( empty( self::$hook_source_stack ) ) { return $output; } $source = self::$hook_source_stack[ count( self::$hook_source_stack ) - 1 ]; // Wrap output that contains HTML tags (as opposed to actions that trigger in HTML attributes). if ( ! empty( $output ) && preg_match( '/<.+?>/s', $output ) ) { $output = implode( '', [ self::get_source_comment( $source, true ), $output, self::get_source_comment( $source, false ), ] ); } return $output; } /** * Get nonce for performing amp_validate request. * * The returned nonce is irrespective of the authenticated user. * * @return string Nonce. */ public static function get_amp_validate_nonce() { return wp_hash( self::VALIDATE_QUERY_VAR . wp_nonce_tick(), 'nonce' ); } /** * Whether the request is to validate URL for validation errors. * * All AMP responses get validated, but when the amp_validate query parameter is present, then the source information * for each validation error is captured and the validation results are returned as JSON instead of the AMP HTML page. * * @return bool|WP_Error Whether to validate. False is returned if it is not a validate request. WP_Error returned * if unauthenticated, unauthorized, and/or invalid nonce supplied. True returned if * validate response should be served. */ public static function should_validate_response() { if ( ! isset( $_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, '