From fa1abba5a788c84c12c2482df665eab16d1c5088 Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 5 Nov 2026 23:02:46 +0100 Subject: [PATCH 1/4] fix(security): restrict iframe allow policy to low-risk features Users without unfiltered_html could set an arbitrary `allow` value and delegate camera, microphone, geolocation or display-capture to any embedded origin. - Filter `allow` directives against an allowlist (filterable on the front end with `blockparty_iframe_allowed_features`) and drop origin lists, in PHP and in the editor preview. - Stop rendering the obsolete `allowpaymentrequest` attribute. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++ includes/BlockRenderer.php | 62 ++++++++++++++++++++++++++++++++-- src/blockparty-iframe/utils.js | 53 ++++++++++++++++++++++++++--- 3 files changed, 116 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd730ab..a4f4914 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 `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`), in the editor and on the front end. 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 on the front end with the `blockparty_iframe_allowed_features` filter. +- **`allowpaymentrequest` removed**: This obsolete attribute is no longer rendered. + ## [1.2.1] - 2026-11-05 ### Security diff --git a/includes/BlockRenderer.php b/includes/BlockRenderer.php index 58a86d7..3f5b28d 100644 --- a/includes/BlockRenderer.php +++ b/includes/BlockRenderer.php @@ -23,12 +23,32 @@ class BlockRenderer { private const ALLOWED_ATTRIBUTES = [ 'allow', 'allowfullscreen', - 'allowpaymentrequest', 'id', 'referrerpolicy', 'sandbox', ]; + /** + * 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. + * Keep in sync with utils.js. + * + * @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. * @@ -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. * diff --git a/src/blockparty-iframe/utils.js b/src/blockparty-iframe/utils.js index f80f21e..eb8f344 100644 --- a/src/blockparty-iframe/utils.js +++ b/src/blockparty-iframe/utils.js @@ -40,12 +40,50 @@ export function isBooleanAttribute( attributeName ) { const ALLOWED_ATTRIBUTES = [ 'allow', 'allowfullscreen', - 'allowpaymentrequest', 'id', 'referrerpolicy', 'sandbox', ]; +/** + * Permissions Policy features that editors can delegate through `allow`. + * + * Sensitive features (camera, microphone, geolocation, display-capture, + * payment…) are left out. Keep in sync with BlockRenderer.php. + */ +const ALLOWED_FEATURES = [ + 'accelerometer', + 'autoplay', + 'clipboard-write', + 'encrypted-media', + 'fullscreen', + 'gyroscope', + 'picture-in-picture', + 'web-share', +]; + +/** + * Keep only allowlisted features from an `allow` Permissions Policy value. + * Origin lists are dropped, so each feature defaults to the iframe origin. + * + * @param {string} value - Raw `allow` attribute value. + * @return {string} Sanitized value, empty when no feature is kept. + */ +export function sanitizeAllowValue( value ) { + if ( typeof value !== 'string' ) { + return ''; + } + + const features = value + .split( ';' ) + .map( ( directive ) => + ( directive.trim().split( /\s+/ )[ 0 ] || '' ).toLowerCase() + ) + .filter( ( name ) => ALLOWED_FEATURES.includes( name ) ); + + return [ ...new Set( features ) ].join( '; ' ); +} + /** * Check if an iframe attribute can be rendered. * @@ -124,9 +162,16 @@ export function convertAttributesToRawProps( attributes ) { */ export function convertAttributesToProps( attributes ) { return convertAttributesToRawProps( - ( attributes || [] ).filter( ( attr ) => - isAllowedIframeAttribute( attr?.key ) - ) + ( attributes || [] ) + .filter( ( attr ) => isAllowedIframeAttribute( attr?.key ) ) + .map( ( attr ) => + attr.key.toLowerCase() === 'allow' + ? { ...attr, value: sanitizeAllowValue( attr.value ) } + : attr + ) + .filter( + ( attr ) => attr.key.toLowerCase() !== 'allow' || attr.value + ) ); } From 123e26ac0e5fc2c7f1c36b92fb07cc0d6fe52a59 Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Tue, 10 Nov 2026 22:42:46 +0100 Subject: [PATCH 2/4] refactor(editor): render block preview with ServerSideRender Render the editor preview through BlockRenderer so iframe attribute filtering lives in PHP only, and the `blockparty_iframe_allowed_features` filter applies to the preview too. - Use ServerSideRender (default import, WordPress 6.7 compatible) with skipBlockSupportAttributes; drop convertAttributesToProps(), sanitizeAllowValue() and the JS ALLOWED_FEATURES list. - Make the nested preview markup fill the block wrapper in the editor. - Check aria-*/data-* attribute prefixes with str_starts_with() and startsWith() instead of a regex. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++- includes/BlockRenderer.php | 12 ++--- package-lock.json | 87 +++++++++++++++++++++++++++---- package.json | 3 +- src/blockparty-iframe/edit.js | 26 ++++----- src/blockparty-iframe/editor.scss | 12 +++++ src/blockparty-iframe/utils.js | 76 +++------------------------ 7 files changed, 121 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f4914..f029ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### 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`), in the editor and on the front end. 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 on the front end with the `blockparty_iframe_allowed_features` filter. +- **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. The preview shows the saved block attributes. + ## [1.2.1] - 2026-11-05 ### Security diff --git a/includes/BlockRenderer.php b/includes/BlockRenderer.php index 3f5b28d..cceefd6 100644 --- a/includes/BlockRenderer.php +++ b/includes/BlockRenderer.php @@ -34,7 +34,6 @@ class BlockRenderer { * 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. - * Keep in sync with utils.js. * * @var string[] */ @@ -50,7 +49,8 @@ class BlockRenderer { ]; /** - * Render the iframe block on the front end. + * 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. * @@ -199,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..cc02727 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 @@ -197,13 +199,13 @@ export default function Edit( { attributes, setAttributes } ) { ) } { showIframe && ( -