Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"phpro/grumphp-shim": true
},
"platform": {
"php": "8.3.28"
"php": "8.3.36"
}
},
"require": {
Expand All @@ -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": {
Expand Down
72 changes: 47 additions & 25 deletions includes/BlockRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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(
'<div %s></div>',
get_block_wrapper_attributes()
Expand All @@ -32,7 +59,7 @@ public static function render( $attributes ) {
$iframe_html = sprintf(
'<iframe title="%1$s" src="%2$s" loading="%3$s"%4$s></iframe>',
esc_attr( $title ),
esc_url( $url ),
$safe_url,
esc_attr( $loading ),
$extra_attrs
);
Expand All @@ -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 )
);
}
Expand All @@ -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 );
}
}
11 changes: 7 additions & 4 deletions src/blockparty-iframe/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
ToolbarGroup,
ToolbarButton,
} from '@wordpress/components';
import { isURL } from '@wordpress/url';

import { useState } from '@wordpress/element';

Expand All @@ -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
Expand Down Expand Up @@ -71,15 +74,15 @@ 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 );

// Validation
const isIframeElligible = Boolean(
iframeData.url?.length &&
isURL( iframeData.url ) &&
isSafeIframeUrl( iframeData.url ) &&
iframeData.title?.length
);

Expand Down
4 changes: 2 additions & 2 deletions src/blockparty-iframe/save.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,7 +31,7 @@ export default function save( { attributes } ) {
title={ title }
src={ url }
loading={ lazyload ? 'lazy' : 'eager' }
{ ...convertAttributesToProps( iframeAttributes ) }
{ ...convertAttributesToRawProps( iframeAttributes ) }
/>
</div>
);
Expand Down
113 changes: 82 additions & 31 deletions src/blockparty-iframe/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 )
)
);
}

/**
Expand All @@ -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 + '</iframe>' );
const iframeElement = tempDiv.querySelector( 'iframe' );
// Parse in an inert document so nothing is loaded or executed.
const doc = new window.DOMParser().parseFromString(
iframeTag + '</iframe>',
'text/html'
);
const iframeElement = doc.querySelector( 'iframe' );

if ( ! iframeElement ) {
return null;
Expand All @@ -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'
Expand Down
Loading