diff --git a/.plugin-data b/.plugin-data index 57b5ffd..f1b8d4b 100644 --- a/.plugin-data +++ b/.plugin-data @@ -1,4 +1,4 @@ { - "version": "1.2.0", + "version": "1.2.1", "slug": "blockparty-iframe" } diff --git a/.wordpress-org/blueprints/blueprint.json b/.wordpress-org/blueprints/blueprint.json index 6eb75c6..44f7b42 100644 --- a/.wordpress-org/blueprints/blueprint.json +++ b/.wordpress-org/blueprints/blueprint.json @@ -11,7 +11,7 @@ "pluginData": { "resource": "git:directory", "url": "https://github.com/BeAPI/blockparty-iframe", - "ref": "1.2.0", + "ref": "1.2.1", "refType": "tag" }, "options": { diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c5c8b..dd730ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ 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/). +## [1.2.1] - 2026-11-05 + +### 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). + +### Fixed + +- **Translations**: Add missing French translations. +- **Developer dependencies**: Bump `wp-coding-standards/wpcs` to 3.4.1 (security advisory on earlier versions) and `config.platform.php` to 8.3.36 so Composer can resolve dev dependencies in CI. + ## [1.2.0] - 2026-04-22 ### Changed diff --git a/blockparty-iframe.php b/blockparty-iframe.php index f214e34..c7b2f97 100644 --- a/blockparty-iframe.php +++ b/blockparty-iframe.php @@ -2,7 +2,7 @@ /** * Plugin Name: Blockparty Iframe * Description: Add a block to display an embedded frame in the WordPress editor. - * Version: 1.2.0 + * Version: 1.2.1 * Requires at least: 6.7 * Requires PHP: 8.1 * Author: Be API Technical team @@ -23,7 +23,7 @@ include_once __DIR__ . '/vendor/autoload.php'; } -define( 'BLOCKPARTY_IFRAME_VERSION', '1.2.0' ); +define( 'BLOCKPARTY_IFRAME_VERSION', '1.2.1' ); define( 'BLOCKPARTY_IFRAME_URL', plugin_dir_url( __FILE__ ) ); define( 'BLOCKPARTY_IFRAME_DIR', plugin_dir_path( __FILE__ ) ); define( 'BLOCKPARTY_IFRAME_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); 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/languages/blockparty-iframe-fr_FR-0e3cdd73079dd5ef56b82e4a974c848c.json b/languages/blockparty-iframe-fr_FR-0e3cdd73079dd5ef56b82e4a974c848c.json index eb610b0..c26caa7 100644 --- a/languages/blockparty-iframe-fr_FR-0e3cdd73079dd5ef56b82e4a974c848c.json +++ b/languages/blockparty-iframe-fr_FR-0e3cdd73079dd5ef56b82e4a974c848c.json @@ -1 +1 @@ -{"translation-revision-date":"2026-01-12 11:32+0100","generator":"WP-CLI\/2.11.0","source":"build\/blockparty-iframe\/index.js","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","lang":"fr_FR","plural-forms":"nplurals=2; plural=(n != 1);"},"Replace":["Remplacer"],"View":["Voir"],"Settings":["R\u00e9glages"],"Enable lazyloading":["Activer le chargement diff\u00e9r\u00e9"],"Iframe":["Cadre"],"Fill the iframe source and the title of the iframe.":["Veuillez remplir la source du cadre ainsi que son titre."],"Source":["Source"],"You can either paste a URL or the iframe code.":["Vous pouvez soit renseigner une URL soit le code HTML du cadre."],"Title":["Titre"],"The title of the iframe is used for accessibility purposes and it will only be visible to screen readers.":["Le titre du cadre est utilis\u00e9 \u00e0 des fins d\u2019accessibilit\u00e9 et ne sera visible que par les technologies d\u2019assistance."],"Add iframe":["Ajouter un cadre"]}}} \ No newline at end of file +{"translation-revision-date":"2026-07-02 17:01+0200","generator":"WP-CLI\/2.11.0","source":"build\/blockparty-iframe\/index.js","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","lang":"fr_FR","plural-forms":"nplurals=2; plural=(n != 1);"},"Replace":["Remplacer"],"View":["Voir"],"Settings":["R\u00e9glages"],"Enable lazyloading":["Activer le chargement diff\u00e9r\u00e9"],"Iframe":["Cadre"],"Fill the iframe source and the title of the iframe.":["Veuillez remplir la source du cadre ainsi que son titre."],"Source":["Source"],"https:\/\/\u2026 or ' ); - 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'