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
PHTML
367 lines
10 KiB
PHTML
8 months ago
|
<?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
|
||
|
);
|
||
|
}
|
||
|
}
|