From fa1abba5a788c84c12c2482df665eab16d1c5088 Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 5 Nov 2026 23:02:46 +0100 Subject: [PATCH] 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 + ) ); }