From 4cd6eb5d9db939de5180d29a1bec469a1b0669a6 Mon Sep 17 00:00:00 2001 From: luisherranz Date: Thu, 5 Nov 2026 22:28:05 +0100 Subject: [PATCH 1/2] Interactivity API: Choose the `data-wp-html` placeholder marker in linear time. `WP_Interactivity_API::process_directives()` holds back the trusted HTML of each `data-wp-html` host behind a placeholder comment, whose marker must occur nowhere in the input. The marker was chosen by appending one underscore at a time and searching the whole input again after each one, so input holding `wp-interactivity-dangerous-html` followed by a long run of underscores took quadratic time. The search runs for every input that holds `data-wp-`, whether or not a `data-wp-html` host renders. The marker is now chosen in a single pass over the input. It is the base followed by one more underscore than the longest run of underscores that follows the base anywhere in the input, or the base alone when the input does not hold it. This is the marker the previous loop chose, so the output does not change. Follow-up to [64386]. Props jonsurrell. Fixes #66498. --- .../class-wp-interactivity-api.php | 25 +++++++++--- .../wpInteractivityAPI-wp-html.php | 39 +++++++++++++++++-- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index d8f11e2d05fe3..b4a7d97cff9be 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -499,14 +499,27 @@ public function process_directives( string $html ): string { * The trusted HTML of `data-wp-html` hosts never takes part in the * processing: each host receives a placeholder comment, and the comments * are replaced by their HTML once the result is known. The marker that - * identifies them is chosen so that it occurs nowhere in the input, which - * makes it impossible for the input to contain a placeholder. + * identifies them occurs nowhere in the input, which makes it impossible + * for the input to contain a placeholder. It is the base + * `wp-interactivity-dangerous-html` followed by one more underscore than + * the longest run of underscores that follows the base anywhere in the + * input, or the base alone when the input does not hold the base. + * + * The input is read in one pass: each search resumes after the previous + * base and its underscores. No base is missed, because the base starts + * with the only `w` it holds, so no base can start inside another one or + * inside a run of underscores. */ - $marker = 'wp-interactivity-dangerous-html'; - while ( str_contains( $html, $marker ) ) { - $marker .= '_'; + $base = 'wp-interactivity-dangerous-html'; + $longest_run = -1; + $found_at = strpos( $html, $base ); + while ( false !== $found_at ) { + $run_start = $found_at + strlen( $base ); + $run_length = strspn( $html, '_', $run_start ); + $longest_run = max( $longest_run, $run_length ); + $found_at = strpos( $html, $base, $run_start + $run_length ); } - $this->html_placeholder_marker = $marker; + $this->html_placeholder_marker = $base . str_repeat( '_', $longest_run + 1 ); $this->html_placeholders = array(); $result = $this->_process_directives( $html ); diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php index ac6dc6625b67b..10e9e717ae665 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php @@ -152,6 +152,17 @@ private function processed_page( string $host ): string { return '
' . $host . 'processed
'; } + /** + * Gets the placeholder the first host of a call would receive if the marker + * were the base followed by the given number of underscores. + * + * @param int $underscores Number of underscores after the marker's base. + * @return string Placeholder lookalike. + */ + private static function lookalike( int $underscores ): string { + return ''; + } + /** * Gets the Interactivity data printed for the client, encoded as the script * module prints it. @@ -671,14 +682,24 @@ public function test_host_inside_a_rendering_host_is_replaced_with_it() { /** * Tests that markup in the input that looks like a placeholder is not - * mistaken for one, however many marker variants it holds. + * mistaken for one, however many marker variants it holds and in whatever + * order. + * + * The lookalikes of each case spell the placeholder of every marker from + * the base alone to the base followed by the longest run of underscores in + * the input, so a marker that occurs in the input would turn one of them + * into the host's HTML. * * @ticket 66246 + * @ticket 66498 * * @covers ::process_directives + * + * @dataProvider data_placeholder_lookalikes + * + * @param string $lookalikes Markup holding placeholder lookalikes. */ - public function test_substitution_cannot_be_forged() { - $lookalikes = ''; + public function test_substitution_cannot_be_forged( string $lookalikes ) { $this->set_state( array( 'html' => $this->token() ) ); $output = wp_interactivity_process_directives( @@ -691,6 +712,18 @@ public function test_substitution_cannot_be_forged() { ); } + /** + * Data provider. + * + * @return array Data provider. + */ + public static function data_placeholder_lookalikes(): array { + return array( + 'longest run last' => array( self::lookalike( 0 ) . self::lookalike( 1 ) ), + 'longest run first' => array( self::lookalike( 3 ) . self::lookalike( 0 ) . self::lookalike( 2 ) . self::lookalike( 1 ) ), + ); + } + /** * Tests that HTML that looks like the placeholder of another host is * inserted as given, not replaced by that host's HTML. From 6c4daf8f9bc73dc6fa3ea2e87a546ae4721ae4d1 Mon Sep 17 00:00:00 2001 From: luisherranz Date: Fri, 6 Nov 2026 14:26:16 +0100 Subject: [PATCH 2/2] Interactivity API: Draw the `data-wp-html` placeholder marker once per call from a UUID. `WP_Interactivity_API::process_directives()` holds back the trusted HTML of each `data-wp-html` host behind a placeholder comment, whose marker identifies the placeholders of that call. The previous commit chose the marker by scanning the input for the longest run of underscores that follows the base. The marker is now the base followed by a UUID from `wp_generate_uuid4()`. The first host of a call that renders draws it, and the later hosts of the call reuse it. Nothing reads the input to choose it, and the placeholders keep a fixed length. A call in which no host renders draws no marker, so ordinary calls take no longer than before. `test_substitution_cannot_be_forged` now also covers a lookalike of the new placeholder form that holds another UUID. `test_inserted_html_is_not_searched_for_placeholders` is removed: with a marker drawn per call, its token's HTML can no longer equal another host's placeholder. Props jonsurrell. See #66498. --- .../class-wp-interactivity-api.php | 36 ++++------ .../wpInteractivityAPI-wp-html.php | 70 ++----------------- 2 files changed, 19 insertions(+), 87 deletions(-) diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index b4a7d97cff9be..fb4f32989d89c 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -153,7 +153,9 @@ final class WP_Interactivity_API { /** * Marker that makes the placeholders of the current `process_directives()` - * call recognizable. It occurs nowhere in the HTML that call received. + * call recognizable, or an empty string until the first `data-wp-html` host + * of the call renders and draws it. It holds a random UUID, so no text + * written before the call can realistically hold it. * * @since 7.2.0 * @var string @@ -498,28 +500,11 @@ public function process_directives( string $html ): string { /* * The trusted HTML of `data-wp-html` hosts never takes part in the * processing: each host receives a placeholder comment, and the comments - * are replaced by their HTML once the result is known. The marker that - * identifies them occurs nowhere in the input, which makes it impossible - * for the input to contain a placeholder. It is the base - * `wp-interactivity-dangerous-html` followed by one more underscore than - * the longest run of underscores that follows the base anywhere in the - * input, or the base alone when the input does not hold the base. - * - * The input is read in one pass: each search resumes after the previous - * base and its underscores. No base is missed, because the base starts - * with the only `w` it holds, so no base can start inside another one or - * inside a run of underscores. + * are replaced by their HTML once the result is known. The call starts + * without the marker that identifies them: the first host that renders + * draws it, so a call without such a host does no marker work. */ - $base = 'wp-interactivity-dangerous-html'; - $longest_run = -1; - $found_at = strpos( $html, $base ); - while ( false !== $found_at ) { - $run_start = $found_at + strlen( $base ); - $run_length = strspn( $html, '_', $run_start ); - $longest_run = max( $longest_run, $run_length ); - $found_at = strpos( $html, $base, $run_start + $run_length ); - } - $this->html_placeholder_marker = $base . str_repeat( '_', $longest_run + 1 ); + $this->html_placeholder_marker = ''; $this->html_placeholders = array(); $result = $this->_process_directives( $html ); @@ -1625,8 +1610,13 @@ private function data_wp_html_processor( WP_Interactivity_API_Directives_Process /* * The HTML is held back and a placeholder comment takes its place, so no * later step of the call scans it. A comment is a single token for the - * processors, and the marker in it occurs nowhere in the input. + * processors. Its marker holds a random UUID, drawn once per call by the + * first host that renders, so no text written before the call can + * realistically contain a placeholder. */ + if ( '' === $this->html_placeholder_marker ) { + $this->html_placeholder_marker = 'wp-interactivity-dangerous-html-' . wp_generate_uuid4(); + } $placeholder = ''; if ( $p->set_raw_content_between_balanced_tags( $placeholder ) ) { $this->html_placeholders[ $placeholder ] = $html; diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php index 10e9e717ae665..8f6fc76124261 100644 --- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php @@ -152,17 +152,6 @@ private function processed_page( string $host ): string { return '
' . $host . 'processed
'; } - /** - * Gets the placeholder the first host of a call would receive if the marker - * were the base followed by the given number of underscores. - * - * @param int $underscores Number of underscores after the marker's base. - * @return string Placeholder lookalike. - */ - private static function lookalike( int $underscores ): string { - return ''; - } - /** * Gets the Interactivity data printed for the client, encoded as the script * module prints it. @@ -681,25 +670,17 @@ public function test_host_inside_a_rendering_host_is_replaced_with_it() { } /** - * Tests that markup in the input that looks like a placeholder is not - * mistaken for one, however many marker variants it holds and in whatever - * order. - * - * The lookalikes of each case spell the placeholder of every marker from - * the base alone to the base followed by the longest run of underscores in - * the input, so a marker that occurs in the input would turn one of them - * into the host's HTML. + * Tests that markup in the input that looks like a placeholder, but holds a + * marker other than the call's own, is not mistaken for one. That includes + * the form of the call's own placeholders with a UUID other than the call's. * * @ticket 66246 - * @ticket 66498 * * @covers ::process_directives - * - * @dataProvider data_placeholder_lookalikes - * - * @param string $lookalikes Markup holding placeholder lookalikes. */ - public function test_substitution_cannot_be_forged( string $lookalikes ) { + public function test_substitution_cannot_be_forged() { + $lookalikes = '' . + ''; $this->set_state( array( 'html' => $this->token() ) ); $output = wp_interactivity_process_directives( @@ -712,45 +693,6 @@ public function test_substitution_cannot_be_forged( string $lookalikes ) { ); } - /** - * Data provider. - * - * @return array Data provider. - */ - public static function data_placeholder_lookalikes(): array { - return array( - 'longest run last' => array( self::lookalike( 0 ) . self::lookalike( 1 ) ), - 'longest run first' => array( self::lookalike( 3 ) . self::lookalike( 0 ) . self::lookalike( 2 ) . self::lookalike( 1 ) ), - ); - } - - /** - * Tests that HTML that looks like the placeholder of another host is - * inserted as given, not replaced by that host's HTML. - * - * @ticket 66246 - * - * @covers ::process_directives - */ - public function test_inserted_html_is_not_searched_for_placeholders() { - $lookalike = ''; - $this->set_state( - array( - 'first' => wp_interactivity_as_dangerous_html( $lookalike ), - 'second' => wp_interactivity_as_dangerous_html( 'second' ), - ) - ); - - $output = wp_interactivity_process_directives( - '

One

Two

' - ); - - $this->assertSame( - '

' . $lookalike . '

second

', - $output - ); - } - /** * Tests that unbalanced markup returns the input unchanged, with no HTML * inserted.