You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

367 lines
10 KiB
PHP

<?php
/**
* Class Cross_Origin_Isolation.
*
* Check if editor screen, add cross origin header and add crossorigin attribute to tags.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\Admin;
use Google\Web_Stories\Context;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Service_Base;
use Google\Web_Stories\User\Preferences;
/**
* Class Cross_Origin_Isolation
*/
class Cross_Origin_Isolation extends Service_Base implements HasRequirements {
/**
* Context instance.
*
* @var Context Context instance.
*/
private Context $context;
/**
* Preferences instance.
*
* @var Preferences Preferences instance.
*/
private Preferences $preferences;
/**
* Constructor.
*
* @since 1.14.0
*
* @param Preferences $preferences Preferences instance.
* @param Context $context Context instance.
*/
public function __construct( Preferences $preferences, Context $context ) {
$this->preferences = $preferences;
$this->context = $context;
}
/**
* Init
*/
public function register(): void {
if ( ! $this->context->is_story_editor() ) {
return;
}
add_action( 'load-post.php', [ $this, 'admin_header' ] );
add_action( 'load-post-new.php', [ $this, 'admin_header' ] );
add_filter( 'style_loader_tag', [ $this, 'style_loader_tag' ], 10, 3 );
add_filter( 'script_loader_tag', [ $this, 'script_loader_tag' ], 10, 3 );
add_filter( 'get_avatar', [ $this, 'get_avatar' ], 10, 6 );
add_action( 'wp_enqueue_media', [ $this, 'override_media_templates' ] );
}
/**
* Get the action to use for registering the service.
*
* @since 1.6.0
*
* @return string Registration action to use.
*/
public static function get_registration_action(): string {
return 'current_screen';
}
/**
* Get the action priority to use for registering the service.
*
* @since 1.6.0
*
* @return int Registration action priority to use.
*/
public static function get_registration_action_priority(): int {
return 11;
}
/**
* Get the list of service IDs required for this service to be registered.
*
* @since 1.12.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'user_preferences' ];
}
/**
* Start output buffer to add headers and `crossorigin` attribute everywhere.
*
* @since 1.6.0
*/
public function admin_header(): void {
if ( $this->needs_isolation() ) {
header( 'Cross-Origin-Opener-Policy: same-origin' );
header( 'Cross-Origin-Embedder-Policy: require-corp' );
}
ob_start( [ $this, 'replace_in_dom' ] );
}
/**
* Filters the HTML link tag of an enqueued style.
*
* @since 1.6.0
*
* @param mixed $tag The link tag for the enqueued style.
* @param string $handle The style's registered handle.
* @param string $href The stylesheet's source URL.
* @return string|mixed
*/
public function style_loader_tag( $tag, string $handle, string $href ) {
return $this->add_attribute( $tag, 'href', $href );
}
/**
* Filters the HTML script tag of an enqueued script.
*
* @since 1.6.0
*
* @param mixed $tag The `<script>` tag for the enqueued script.
* @param string $handle The script's registered handle.
* @param string $src The script's source URL.
* @return string|mixed The filtered script tag.
*/
public function script_loader_tag( $tag, string $handle, string $src ) {
return $this->add_attribute( $tag, 'src', $src );
}
/**
* Filter the avatar tag.
*
* @since 1.6.0
*
* @param string|mixed $avatar HTML for the user's avatar.
* @param mixed $id_or_email The avatar to retrieve. Accepts a user_id, Gravatar MD5 hash,
* user email, WP_User object, WP_Post object, or WP_Comment object.
* @param mixed $size Square avatar width and height in pixels to retrieve.
* @param mixed $default_url URL for the default image or a default type. Accepts '404', 'retro', 'monsterid',
* 'wavatar', 'indenticon', 'mystery', 'mm', 'mysteryman', 'blank', or
* 'gravatar_default'. Default is the value of the 'avatar_default' option, with a
* fallback of 'mystery'.
* @param mixed $alt Alternative text to use in the avatar image tag. Default empty.
* @param array<string,mixed> $args Arguments passed to get_avatar_data(), after processing.
* @return string|mixed Filtered avatar tag.
*/
public function get_avatar( $avatar, $id_or_email, $size, $default_url, $alt, array $args ) {
return $this->add_attribute( $avatar, 'src', $args['url'] );
}
/**
* Unhook wp_print_media_templates and replace with custom media templates.
*
* @since 1.8.0
*/
public function override_media_templates(): void {
remove_action( 'admin_footer', 'wp_print_media_templates' );
add_action( 'admin_footer', [ $this, 'custom_print_media_templates' ] );
}
/**
* Add crossorigin attribute to all tags that could have assets loaded from a different domain.
*
* @since 1.8.0
*/
public function custom_print_media_templates(): void {
ob_start();
wp_print_media_templates();
$html = (string) ob_get_clean();
$tags = [
'audio',
'img',
'video',
];
foreach ( $tags as $tag ) {
$html = (string) str_replace( '<' . $tag, '<' . $tag . ' crossorigin="anonymous"', $html );
}
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Determines whether "full" cross-origin isolation is needed.
*
* By default, `crossorigin="anonymous"` attributes are added to all external
* resources to make sure they can be accessed programmatically (e.g. by html-to-image).
*
* However, actual cross-origin isolation by sending COOP and COEP headers is only
* needed when video optimization is enabled
*
* @since 1.14.0
*
* @link https://github.com/googleforcreators/web-stories-wp/issues/9327
* @link https://web.dev/coop-coep/
*
* @return bool Whether the conditional object is needed.
*/
private function needs_isolation(): bool {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return false;
}
// Cross-origin isolation is not needed if users can't upload files anyway.
if ( ! user_can( $user_id, 'upload_files' ) ) {
return false;
}
/**
* Whether the user has opted in to video optimization.
*
* @var string|bool $preference
*/
$preference = $this->preferences->get_preference( $user_id, $this->preferences::MEDIA_OPTIMIZATION_META_KEY );
return rest_sanitize_boolean( $preference );
}
/**
* Process a html string and add attribute attributes to required tags.
*
* @since 1.6.0
*
* @param string $html HTML document as string.
* @return string Processed HTML document.
*/
protected function replace_in_dom( string $html ): string { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
$site_url = site_url();
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin.
$tags = [
'audio',
'img',
'link',
'script',
'video',
];
$tags = implode( '|', $tags );
$matches = [];
$processed = [];
if ( preg_match_all( '#<(?P<tag>' . $tags . ')[^<]*?(?:>[\s\S]*?</(?P=tag)>|\s*/>)#', $html, $matches ) ) {
/**
* Single match.
*
* @var string $match
*/
foreach ( $matches[0] as $index => $match ) {
$tag = $matches['tag'][ $index ];
if ( str_contains( $match, ' crossorigin=' ) ) {
continue;
}
$match_value = [];
if ( ! preg_match( '/(src|href)=("([^"]+)"|\'([^\']+)\')/', $match, $match_value ) ) {
continue;
}
$attribute = $match_value[1];
$value = $match_value[4] ?? $match_value[3];
$cache_key = 'video' === $tag || 'audio' === $tag ? $tag : $attribute;
// If already processed tag/attribute and value before, skip.
if ( isset( $processed[ $cache_key ] ) && \in_array( $value, $processed[ $cache_key ], true ) ) {
continue;
}
$processed[ $cache_key ][] = $value;
// The only tags that can have <source> children.
if ( 'video' === $tag || 'audio' === $tag ) {
if ( ! str_starts_with( $value, $site_url ) && ! str_starts_with( $value, '/' ) ) {
$html = str_replace( $match, str_replace( '<' . $tag, '<' . $tag . ' crossorigin="anonymous"', $match ), $html );
}
} else {
/**
* Modified HTML.
*
* @var string $html
*/
$html = $this->add_attribute( $html, $attribute, $value );
}
}
}
return $html;
}
/**
* Do replacement to add crossorigin attribute.
*
* @since 1.6.0
*
* @param string|mixed $html HTML string.
* @param string $attribute Attribute to check for.
* @param string|null|mixed $url URL.
* @return string|mixed Filtered HTML string.
*/
protected function add_attribute( $html, string $attribute, $url ) {
/**
* URL.
*
* @var string $url
*/
if ( ! $url || ! \is_string( $html ) ) {
return $html;
}
$site_url = site_url();
$url = esc_url( $url );
if ( str_starts_with( $url, $site_url ) ) {
return $html;
}
if ( str_starts_with( $url, '/' ) ) {
return $html;
}
return str_replace(
[
$attribute . '="' . $url . '"',
"{$attribute}='{$url}'",
],
[
'crossorigin="anonymous" ' . $attribute . '="' . $url . '"',
"crossorigin='anonymous' {$attribute}='{$url}'",
],
$html
);
}
}