diff --git a/CHANGELOG.md b/CHANGELOG.md index dd730ab..2ff4503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ 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 `allow` policy**: The `allow` attribute value is filtered against an allowlist of low-risk Permissions Policy features (`accelerometer`, `autoplay`, `clipboard-write`, `encrypted-media`, `fullscreen`, `gyroscope`, `picture-in-picture`, `web-share`). Origin lists are dropped, so features only apply to the iframe origin. Users without `unfiltered_html` can no longer delegate camera, microphone, geolocation, screen capture or payment to an embedded page. The list can be extended with the `blockparty_iframe_allowed_features` filter. +- **`allowpaymentrequest` removed**: This obsolete attribute is no longer rendered. + +### Changed + +- **Editor preview rendered on the server**: The editor preview uses `ServerSideRender`, so it goes through `BlockRenderer` like the front end. Iframe attribute filtering (and the `blockparty_iframe_allowed_features` filter) now lives in PHP only; the duplicated JS allowlists are removed. Adds the `@wordpress/server-side-render` dependency. + ## [1.2.1] - 2026-11-05 ### Security diff --git a/includes/BlockRenderer.php b/includes/BlockRenderer.php index 58a86d7..cceefd6 100644 --- a/includes/BlockRenderer.php +++ b/includes/BlockRenderer.php @@ -23,14 +23,34 @@ class BlockRenderer { private const ALLOWED_ATTRIBUTES = [ 'allow', 'allowfullscreen', - 'allowpaymentrequest', 'id', 'referrerpolicy', 'sandbox', ]; /** - * Render the iframe block on the front end. + * Permissions Policy features that editors can delegate through `allow`. + * + * Sensitive features (camera, microphone, geolocation, display-capture, + * payment…) are left out: users without `unfiltered_html` must not be + * able to grant them to a third-party document on behalf of the site. + * + * @var string[] + */ + private const ALLOWED_FEATURES = [ + 'accelerometer', + 'autoplay', + 'clipboard-write', + 'encrypted-media', + 'fullscreen', + 'gyroscope', + 'picture-in-picture', + 'web-share', + ]; + + /** + * Render the iframe block, on the front end and in the editor preview + * (ServerSideRender), so attribute filtering only lives here. * * @param array $attributes Block attributes. * @@ -93,6 +113,14 @@ private static function build_iframe_attribute_string( array $attributes ): stri continue; } + if ( 'allow' === $key ) { + $value = self::sanitize_allow_value( $value ); + + if ( '' === $value ) { + continue; + } + } + if ( self::is_boolean_attribute( $key ) ) { if ( 'true' === $value || '' === $value ) { $html .= ' ' . $key; @@ -120,12 +148,42 @@ private static function build_iframe_attribute_string( array $attributes ): stri private static function is_boolean_attribute( string $attribute_name ): bool { $boolean_attrs = [ 'allowfullscreen', - 'allowpaymentrequest', ]; return in_array( strtolower( $attribute_name ), $boolean_attrs, true ); } + /** + * Keep only allowlisted features from an `allow` Permissions Policy value. + * + * Origin lists are dropped, so each kept feature defaults to the iframe + * source origin only. + * + * @param string $value Raw `allow` attribute value. + * + * @return string Sanitized value, empty when no feature is kept. + */ + private static function sanitize_allow_value( string $value ): string { + /** + * Filters the Permissions Policy features that editors can delegate to an iframe. + * + * @param string[] $features Allowed feature names. + */ + $features = (array) apply_filters( 'blockparty_iframe_allowed_features', self::ALLOWED_FEATURES ); + $kept = []; + + foreach ( explode( ';', $value ) as $directive ) { + $parts = preg_split( '/\s+/', trim( $directive ) ); + $name = is_array( $parts ) ? strtolower( $parts[0] ) : ''; + + if ( '' !== $name && in_array( $name, $features, true ) ) { + $kept[ $name ] = $name; + } + } + + return implode( '; ', $kept ); + } + /** * Check if an iframe attribute can be output. * @@ -141,10 +199,8 @@ private static function is_allowed_iframe_attribute( string $attribute_name ): b return false; } - if ( in_array( $attribute_name, self::ALLOWED_ATTRIBUTES, true ) ) { - return true; - } - - return 1 === preg_match( '/^(aria|data)-[a-z0-9-]+$/', $attribute_name ); + return in_array( $attribute_name, self::ALLOWED_ATTRIBUTES, true ) + || str_starts_with( $attribute_name, 'aria-' ) + || str_starts_with( $attribute_name, 'data-' ); } } diff --git a/package-lock.json b/package-lock.json index 3679399..acca5f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@wordpress/components": "32.6.0", "@wordpress/element": "6.44.0", "@wordpress/i18n": "6.17.0", - "@wordpress/icons": "^12.2.0" + "@wordpress/icons": "^12.2.0", + "@wordpress/server-side-render": "6.20.0" }, "devDependencies": { "@wordpress/env": "latest", @@ -8028,6 +8029,47 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/api-fetch": { + "version": "7.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.57.0.tgz", + "integrity": "sha512-NM9whtviaDmHmoJORHCZ1TqDRiC0nPooN/IqGEwfr2nX1X8F6d4hvOceiOZJJDTXQzdJoun5c9oUNQ4IrABQ4w==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/i18n": "^6.30.0", + "@wordpress/private-apis": "^2.0.0", + "@wordpress/url": "^4.57.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/api-fetch/node_modules/@wordpress/i18n": { + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.30.0.tgz", + "integrity": "sha512-Q8ivagc2aG+Qe5dTzmiohR+pDo4xDcD746fGI2dq+JIbTibYRnkFoN1VyGFZX/Q+g98I80ZJLJ5Vk3svSXb9ag==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@tannin/sprintf": "^1.3.2", + "@wordpress/hooks": "^4.57.0", + "gettext-parser": "^1.3.1", + "tannin": "^1.2.0" + }, + "bin": { + "pot-to-php": "tools/pot-to-php.js" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@wordpress/api-fetch/node_modules/@wordpress/private-apis": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-2.0.0.tgz", + "integrity": "sha512-VmGwebQZoebF5t1xFMXbj6z/dTtAca4d8YaO3IPN2aP8IT+eOrUBvoAthC5BmLPU4JLN5m3r8vbrid3mcli6vw==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0" + } + }, "node_modules/@wordpress/autop": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-4.44.0.tgz", @@ -9069,13 +9111,12 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", - "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.57.0.tgz", + "integrity": "sha512-vRJxRhOj+a19RvdW6u0ZBmiQc/t/ueVGsIP3IbS9zuvfLJILtvMSWXU8Fe3m/W+mBK5ZEOfm72LYBhWGPse0DA==", "license": "GPL-2.0-or-later", "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/html-entities": { @@ -9503,6 +9544,31 @@ } } }, + "node_modules/@wordpress/server-side-render": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@wordpress/server-side-render/-/server-side-render-6.20.0.tgz", + "integrity": "sha512-89QVTA0SlftdgGtqfFQWxja7/ocMPJtcJbeiPbfzULWom0DxuwUt3CG1/vTs+SOYtzWSaxM3PJZJLI/ZXyCtdQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/api-fetch": "^7.44.0", + "@wordpress/blocks": "^15.17.0", + "@wordpress/components": "^32.6.0", + "@wordpress/compose": "^7.44.0", + "@wordpress/data": "^10.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/element": "^6.44.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/url": "^4.44.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@wordpress/shortcode": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.44.0.tgz", @@ -9704,16 +9770,15 @@ } }, "node_modules/@wordpress/url": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.44.0.tgz", - "integrity": "sha512-kWalXttgtRwFy4szBPX9dJcqHErRC0V9JuZ7uxdrxxdXl6WNv+lx8SYpLx12q3Zk6zNIw73M8E5wHON7eyXZZw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.57.0.tgz", + "integrity": "sha512-2y8No4z7GNlrNjR7hUTThSG7hVRDAQixwFxPj8JUydjMS9nv02PBgZhy7XakFXVZFw2PsmajTW56+BdQFKp96A==", "license": "GPL-2.0-or-later", "dependencies": { "remove-accents": "^0.5.0" }, "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" + "node": ">=18.12.0" } }, "node_modules/@wordpress/vips": { diff --git a/package.json b/package.json index b5b998f..b545aed 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "@wordpress/components": "32.6.0", "@wordpress/element": "6.44.0", "@wordpress/i18n": "6.17.0", - "@wordpress/icons": "^12.2.0" + "@wordpress/icons": "^12.2.0", + "@wordpress/server-side-render": "6.20.0" }, "volta": { "node": "24.15.0" diff --git a/src/blockparty-iframe/edit.js b/src/blockparty-iframe/edit.js index fa48063..e4d6e39 100644 --- a/src/blockparty-iframe/edit.js +++ b/src/blockparty-iframe/edit.js @@ -29,6 +29,12 @@ import { import { useState } from '@wordpress/element'; +/** + * Default export: the named `ServerSideRender` export only exists in the + * `wp.serverSideRender` global since WordPress 6.9, and the plugin supports 6.7. + */ +import ServerSideRender from '@wordpress/server-side-render'; + /** * Lets webpack process CSS, SASS or SCSS files referenced in JavaScript files. * Those files can contain any CSS code that gets applied to the editor. @@ -38,11 +44,7 @@ import { useState } from '@wordpress/element'; import './editor.scss'; import { aspectRatio } from '@wordpress/icons'; -import { - convertAttributesToProps, - isSafeIframeUrl, - parseIframeCode, -} from './utils'; +import { isSafeIframeUrl, parseIframeCode } from './utils'; /** * The edit function describes the structure of your block in the context of the @@ -86,6 +88,10 @@ export default function Edit( { attributes, setAttributes } ) { iframeData.title?.length ); + // Block supports (align, dimensions) already style the editor wrapper: + // skipBlockSupportAttributes keeps `style.dimensions`, so drop them here. + const { align, style, ...previewAttributes } = attributes; + const showPlaceholder = ! hasConfirmed; const showIframe = hasConfirmed && isIframeElligible; @@ -197,13 +203,14 @@ export default function Edit( { attributes, setAttributes } ) { ) } { showIframe && ( -