diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c5c8b..547ed38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Security + +- **Iframe attributes allowlist**: Only `allow`, `allowfullscreen`, `allowpaymentrequest`, `id`, `referrerpolicy`, `sandbox`, `aria-*` and `data-*` attributes are rendered, in the editor and on the front end. Event handlers (`onload`…), `srcdoc` and malformed attribute names are dropped, closing a stored XSS reachable by contributors. +- **Iframe source protocols**: The iframe source must be an `http` or `https` URL, in the editor and on the front end (blocks `javascript:` URLs in the editor). + ## [1.2.0] - 2026-04-22 ### Changed diff --git a/composer.json b/composer.json index 2f69550..92608e0 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "phpro/grumphp-shim": true }, "platform": { - "php": "8.3.28" + "php": "8.3.36" } }, "require": { @@ -45,7 +45,7 @@ "roots/wordpress-no-content": "^6.0", "squizlabs/php_codesniffer": "^3.7", "wp-cli/wp-cli": "^2.6", - "wp-coding-standards/wpcs": "3.3.0" + "wp-coding-standards/wpcs": "3.4.1" }, "autoload": { "psr-4": { diff --git a/includes/BlockRenderer.php b/includes/BlockRenderer.php index 955ccd3..58a86d7 100644 --- a/includes/BlockRenderer.php +++ b/includes/BlockRenderer.php @@ -4,6 +4,31 @@ class BlockRenderer { + /** + * URL protocols allowed for the iframe source. + * + * @var string[] + */ + private const ALLOWED_PROTOCOLS = [ 'http', 'https' ]; + + /** + * Iframe attribute names that can be added by editors. + * + * Any other attribute (event handlers such as `onload`, `srcdoc`, …) is + * dropped, since block attributes are controlled by users who may not + * have the `unfiltered_html` capability. + * + * @var string[] + */ + private const ALLOWED_ATTRIBUTES = [ + 'allow', + 'allowfullscreen', + 'allowpaymentrequest', + 'id', + 'referrerpolicy', + 'sandbox', + ]; + /** * Render the iframe block on the front end. * @@ -19,7 +44,9 @@ public static function render( $attributes ) { ? $attributes['iframeAttributes'] : []; - if ( '' === $url || '' === $title ) { + $safe_url = '' !== $url ? esc_url( $url, self::ALLOWED_PROTOCOLS ) : ''; + + if ( '' === $safe_url || '' === $title ) { return sprintf( '
', get_block_wrapper_attributes() @@ -32,7 +59,7 @@ public static function render( $attributes ) { $iframe_html = sprintf( '', esc_attr( $title ), - esc_url( $url ), + $safe_url, esc_attr( $loading ), $extra_attrs ); @@ -59,23 +86,23 @@ private static function build_iframe_attribute_string( array $attributes ): stri continue; } - $key = (string) $attribute['key']; + $key = strtolower( (string) $attribute['key'] ); $value = isset( $attribute['value'] ) ? (string) $attribute['value'] : ''; - if ( self::is_excluded_iframe_attribute( $key ) ) { + if ( ! self::is_allowed_iframe_attribute( $key ) ) { continue; } if ( self::is_boolean_attribute( $key ) ) { if ( 'true' === $value || '' === $value ) { - $html .= ' ' . esc_attr( strtolower( $key ) ); + $html .= ' ' . $key; } continue; } $html .= sprintf( ' %1$s="%2$s"', - esc_attr( $key ), + $key, esc_attr( $value ) ); } @@ -100,29 +127,24 @@ private static function is_boolean_attribute( string $attribute_name ): bool { } /** - * Check if an iframe attribute should be excluded from custom output. + * Check if an iframe attribute can be output. * - * @param string $attribute_name Attribute name. + * The name must be a plain lowercase attribute name (no whitespace, quotes + * or `=`), and either be allowlisted or be an `aria-*` / `data-*` attribute. + * + * @param string $attribute_name Lowercased attribute name. * * @return bool */ - private static function is_excluded_iframe_attribute( string $attribute_name ): bool { - $excluded_attrs = [ - 'src', - 'loading', - 'title', - 'width', - 'height', - 'style', - 'frameborder', - 'marginwidth', - 'marginheight', - 'scrolling', - 'align', - 'longdesc', - 'name', - ]; + private static function is_allowed_iframe_attribute( string $attribute_name ): bool { + if ( 1 !== preg_match( '/^[a-z][a-z0-9-]*$/', $attribute_name ) ) { + return false; + } + + if ( in_array( $attribute_name, self::ALLOWED_ATTRIBUTES, true ) ) { + return true; + } - return in_array( strtolower( $attribute_name ), $excluded_attrs, true ); + return 1 === preg_match( '/^(aria|data)-[a-z0-9-]+$/', $attribute_name ); } } diff --git a/src/blockparty-iframe/edit.js b/src/blockparty-iframe/edit.js index a8ee13c..fa48063 100644 --- a/src/blockparty-iframe/edit.js +++ b/src/blockparty-iframe/edit.js @@ -26,7 +26,6 @@ import { ToolbarGroup, ToolbarButton, } from '@wordpress/components'; -import { isURL } from '@wordpress/url'; import { useState } from '@wordpress/element'; @@ -39,7 +38,11 @@ import { useState } from '@wordpress/element'; import './editor.scss'; import { aspectRatio } from '@wordpress/icons'; -import { convertAttributesToProps, parseIframeCode } from './utils'; +import { + convertAttributesToProps, + isSafeIframeUrl, + parseIframeCode, +} from './utils'; /** * The edit function describes the structure of your block in the context of the @@ -71,7 +74,7 @@ export default function Edit( { attributes, setAttributes } ) { // hasConfirmed = l’utilisateur a validé l’ajout de l’iframe const initialConfirmed = Boolean( iframeData.url?.length && - isURL( iframeData.url ) && + isSafeIframeUrl( iframeData.url ) && iframeData.title?.length ); const [ hasConfirmed, setHasConfirmed ] = useState( initialConfirmed ); @@ -79,7 +82,7 @@ export default function Edit( { attributes, setAttributes } ) { // Validation const isIframeElligible = Boolean( iframeData.url?.length && - isURL( iframeData.url ) && + isSafeIframeUrl( iframeData.url ) && iframeData.title?.length ); diff --git a/src/blockparty-iframe/save.js b/src/blockparty-iframe/save.js index 4ebd8be..a27458b 100644 --- a/src/blockparty-iframe/save.js +++ b/src/blockparty-iframe/save.js @@ -5,7 +5,7 @@ * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops */ import { useBlockProps } from '@wordpress/block-editor'; -import { convertAttributesToProps } from './utils'; +import { convertAttributesToRawProps } from './utils'; /** * The save function defines the way in which the different attributes should @@ -31,7 +31,7 @@ export default function save( { attributes } ) { title={ title } src={ url } loading={ lazyload ? 'lazy' : 'eager' } - { ...convertAttributesToProps( iframeAttributes ) } + { ...convertAttributesToRawProps( iframeAttributes ) } /> ); diff --git a/src/blockparty-iframe/utils.js b/src/blockparty-iframe/utils.js index ba6df66..f80f21e 100644 --- a/src/blockparty-iframe/utils.js +++ b/src/blockparty-iframe/utils.js @@ -31,13 +31,73 @@ export function isBooleanAttribute( attributeName ) { } /** - * Convert iframe attributes array to props object for React. - * Handles boolean attributes and React prop name mapping correctly. + * Iframe attribute names that can be added by editors. + * + * Any other attribute (event handlers such as `onload`, `srcdoc`, …) is + * dropped: block attributes are controlled by users who may not have the + * `unfiltered_html` capability. Keep in sync with BlockRenderer.php. + */ +const ALLOWED_ATTRIBUTES = [ + 'allow', + 'allowfullscreen', + 'allowpaymentrequest', + 'id', + 'referrerpolicy', + 'sandbox', +]; + +/** + * Check if an iframe attribute can be rendered. + * + * @param {string} attributeName - The name of the attribute to check. + * @return {boolean} True if the attribute is allowed, false otherwise. + */ +export function isAllowedIframeAttribute( attributeName ) { + if ( typeof attributeName !== 'string' ) { + return false; + } + + const name = attributeName.toLowerCase(); + + if ( ! /^[a-z][a-z0-9-]*$/.test( name ) ) { + return false; + } + + return ( + ALLOWED_ATTRIBUTES.includes( name ) || + /^(aria|data)-[a-z0-9-]+$/.test( name ) + ); +} + +/** + * Check if a URL can be used as iframe source (http or https only). + * + * @param {string} url - The URL to check. + * @return {boolean} True if the URL is safe, false otherwise. + */ +export function isSafeIframeUrl( url ) { + if ( typeof url !== 'string' || ! url.length ) { + return false; + } + + try { + const { protocol } = new URL( url ); + return protocol === 'http:' || protocol === 'https:'; + } catch ( error ) { + return false; + } +} + +/** + * Convert an iframe attributes array to a React props object, without filtering. + * + * Only meant for the deprecated save function, whose output must match the + * markup previously stored in post content and is never mounted in the DOM. * * @param {Array} attributes - Array of {key, value} objects. * @return {Object} Props object for React component. */ -export function convertAttributesToProps( attributes ) { +export function convertAttributesToRawProps( attributes ) { return Object.fromEntries( ( attributes || [] ).map( ( attr ) => { // Map HTML attribute name to React prop name @@ -55,29 +115,19 @@ export function convertAttributesToProps( attributes ) { } /** - * Check if an iframe attribute should be excluded. + * Convert iframe attributes array to props object for React. + * Drops non allowed attributes, handles boolean attributes and React prop + * name mapping. * - * @param {string} attributeName - The name of the attribute to check. - * @return {boolean} True if the attribute should be excluded, false otherwise. + * @param {Array} attributes - Array of {key, value} objects. + * @return {Object} Props object for React component. */ -export function isExcludedIframeAttribute( attributeName ) { - const excludedAttrs = [ - 'src', // Managed separately - 'loading', // Managed by lazyload option - 'title', // Managed separately - 'width', // Managed by block dimension supports - 'height', // Managed by block dimension supports - 'style', // Requires object format in React, not string - 'frameborder', // Deprecated HTML attribute - 'marginwidth', // Deprecated HTML attribute - 'marginheight', // Deprecated HTML attribute - 'scrolling', // Deprecated HTML attribute - 'align', // Deprecated HTML attribute - 'longdesc', // Deprecated HTML attribute - 'name', // Can cause conflicts - ]; - - return excludedAttrs.includes( attributeName.toLowerCase() ); +export function convertAttributesToProps( attributes ) { + return convertAttributesToRawProps( + ( attributes || [] ).filter( ( attr ) => + isAllowedIframeAttribute( attr?.key ) + ) + ); } /** @@ -98,11 +148,12 @@ export function parseIframeCode( value ) { // Extract only the opening tag (ignore any content inside iframe) const iframeTag = match[ 0 ]; - // Create a temporary DOM element to parse the HTML - // Use a self-closing iframe to avoid parsing issues with content - const tempDiv = document.createElement( 'div' ); - tempDiv.insertAdjacentHTML( 'beforeend', iframeTag + '' ); - const iframeElement = tempDiv.querySelector( 'iframe' ); + // Parse in an inert document so nothing is loaded or executed. + const doc = new window.DOMParser().parseFromString( + iframeTag + '', + 'text/html' + ); + const iframeElement = doc.querySelector( 'iframe' ); if ( ! iframeElement ) { return null; @@ -114,11 +165,11 @@ export function parseIframeCode( value ) { // Extract title attribute const title = iframeElement.getAttribute( 'title' ) || ''; - // Extract all other attributes (excluding managed and deprecated ones) + // Extract other allowed attributes only const attributes = []; for ( const attr of iframeElement.attributes ) { - if ( ! isExcludedIframeAttribute( attr.name ) ) { + if ( isAllowedIframeAttribute( attr.name ) ) { // For boolean attributes, store 'true' as value if present const attrValue = isBooleanAttribute( attr.name ) ? 'true'