Skip to content

Commit 3effce4

Browse files
committed
Interactivity API: Add server-side processing for the data-wp-html directive.
Adds server-side processing for the `data-wp-html` directive, whose client implementation was added in Gutenberg, so that trusted HTML produced in PHP is present in the server-rendered page. The new `wp_interactivity_as_dangerous_html()` function returns a `WP_Interactivity_Dangerous_HTML` token that marks an HTML string as trusted. Only these tokens render. The HTML is neither sanitized nor processed for directives, and it is never included in the data printed for the client, where a token is encoded as `{}`. An unserialized token holds no HTML and does not render. When the directive cannot render, the element keeps its existing content and a `_doing_it_wrong()` notice is raised. That happens when the value is not a token, when the element cannot hold content, or when the element also uses `data-wp-text` or `data-wp-each` on a `template`. Also adds `WP_Interactivity_API_Directives_Processor::set_raw_content_between_balanced_tags()`, which replaces the content between balanced tags without escaping it. Props darerodz. Fixes #66246. git-svn-id: https://develop.svn.wordpress.org/trunk@64386 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 7d64ecf commit 3effce4

7 files changed

Lines changed: 1261 additions & 6 deletions

‎src/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,25 @@ public function get_content_between_balanced_template_tags() {
7171
* @return bool Whether the content was successfully replaced.
7272
*/
7373
public function set_content_between_balanced_tags( string $new_content ): bool {
74+
return $this->set_raw_content_between_balanced_tags( esc_html( $new_content ) );
75+
}
76+
77+
/**
78+
* Sets the content between two balanced tags, writing it as given.
79+
*
80+
* Unlike {@see WP_Interactivity_API_Directives_Processor::set_content_between_balanced_tags()},
81+
* it does not escape the content. Like it, it replaces the content between
82+
* the current opener tag and its matching closer tag, and it rewinds to the
83+
* opener tag.
84+
*
85+
* @since 7.2.0
86+
*
87+
* @access private
88+
*
89+
* @param string $new_content The markup to replace the content between the matching tags. It is written as is.
90+
* @return bool Whether the content was successfully replaced.
91+
*/
92+
public function set_raw_content_between_balanced_tags( string $new_content ): bool {
7493
$positions = $this->get_after_opener_tag_and_before_closer_tag_positions( true );
7594
if ( ! $positions ) {
7695
return false;
@@ -80,7 +99,7 @@ public function set_content_between_balanced_tags( string $new_content ): bool {
8099
$this->lexical_updates[] = new WP_HTML_Text_Replacement(
81100
$after_opener_tag,
82101
$before_closer_tag - $after_opener_tag,
83-
esc_html( $new_content )
102+
$new_content
84103
);
85104

86105
return true;

‎src/wp-includes/interactivity-api/class-wp-interactivity-api.php‎

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ final class WP_Interactivity_API {
2626
* 'data-wp-class': 'data_wp_class_processor',
2727
* 'data-wp-style': 'data_wp_style_processor',
2828
* 'data-wp-text': 'data_wp_text_processor',
29+
* 'data-wp-html': 'data_wp_html_processor',
2930
* 'data-wp-each': 'data_wp_each_processor',
3031
* }
3132
*/
@@ -37,6 +38,12 @@ final class WP_Interactivity_API {
3738
'data-wp-class' => 'data_wp_class_processor',
3839
'data-wp-style' => 'data_wp_style_processor',
3940
'data-wp-text' => 'data_wp_text_processor',
41+
/*
42+
* `data-wp-html` is processed after `data-wp-text`, which it cannot be
43+
* combined with, and before `data-wp-each`, so that it resolves its
44+
* reference in the namespace and context its own element declares.
45+
*/
46+
'data-wp-html' => 'data_wp_html_processor',
4047
/*
4148
* `data-wp-each` needs to be processed in the last place because it moves
4249
* the cursor to the end of the processed items to prevent them to be
@@ -144,6 +151,25 @@ final class WP_Interactivity_API {
144151
*/
145152
private $current_element = null;
146153

154+
/**
155+
* Marker that makes the placeholders of the current `process_directives()`
156+
* call recognizable. It occurs nowhere in the HTML that call received.
157+
*
158+
* @since 7.2.0
159+
* @var string
160+
*/
161+
private $html_placeholder_marker = '';
162+
163+
/**
164+
* Trusted HTML written by `data-wp-html` during the current
165+
* `process_directives()` call, indexed by the placeholder comment standing
166+
* in for it until the call has finished.
167+
*
168+
* @since 7.2.0
169+
* @var array<string, string>
170+
*/
171+
private $html_placeholders = array();
172+
147173
/**
148174
* Gets and/or sets the initial state of an Interactivity API store for a
149175
* given namespace.
@@ -455,6 +481,8 @@ public function add_client_navigation_support_to_script_module( string $script_m
455481
* and updates the markup accordingly.
456482
*
457483
* @since 6.5.0
484+
* @since 7.2.0 The output includes the trusted HTML that `data-wp-html` renders. That HTML is
485+
* added once the whole call has finished, so it is never processed.
458486
*
459487
* @param string $html The HTML content to process.
460488
* @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
@@ -467,12 +495,34 @@ public function process_directives( string $html ): string {
467495
$this->namespace_stack = array();
468496
$this->context_stack = array();
469497

498+
/*
499+
* The trusted HTML of `data-wp-html` hosts never takes part in the
500+
* processing: each host receives a placeholder comment, and the comments
501+
* are replaced by their HTML once the result is known. The marker that
502+
* identifies them is chosen so that it occurs nowhere in the input, which
503+
* makes it impossible for the input to contain a placeholder.
504+
*/
505+
$marker = 'wp-interactivity-dangerous-html';
506+
while ( str_contains( $html, $marker ) ) {
507+
$marker .= '_';
508+
}
509+
$this->html_placeholder_marker = $marker;
510+
$this->html_placeholders = array();
511+
470512
$result = $this->_process_directives( $html );
471513

472514
$this->namespace_stack = null;
473515
$this->context_stack = null;
474516

475-
return $result ?? $html;
517+
$placeholders = $this->html_placeholders;
518+
$this->html_placeholders = array();
519+
520+
if ( null === $result ) {
521+
return $html;
522+
}
523+
524+
// `strtr()` never searches the replacements it has inserted.
525+
return strtr( $result, $placeholders );
476526
}
477527

478528
/**
@@ -656,6 +706,7 @@ private function _process_directives( string $html ) {
656706
* @since 6.6.0 Removed `default_namespace` and `context` arguments.
657707
* @since 6.6.0 Add support for derived state.
658708
* @since 6.9.0 Receive $entry as an argument instead of the directive value string.
709+
* @since 7.2.0 Tracks the path of a token that marks HTML as trusted, reached through state.
659710
*
660711
* @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
661712
* @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
@@ -681,8 +732,9 @@ private function evaluate( $entry ) {
681732
$path = $should_negate_value ? substr( $path, 1 ) : $path;
682733

683734
// Extracts the value from the store using the reference path.
684-
$path_segments = explode( '.', $path );
685-
$current = $store;
735+
$path_segments = explode( '.', $path );
736+
$current = $store;
737+
$called_closure = false;
686738
foreach ( $path_segments as $index => $path_segment ) {
687739
/*
688740
* Special case for numeric arrays and strings. Add length
@@ -731,7 +783,8 @@ private function evaluate( $entry ) {
731783
*/
732784
array_push( $this->namespace_stack, $ns );
733785
try {
734-
$current = $current();
786+
$called_closure = true;
787+
$current = $current();
735788

736789
/*
737790
* Tracks derived state properties that are accessed during
@@ -765,6 +818,28 @@ private function evaluate( $entry ) {
765818
}
766819
}
767820

821+
/*
822+
* Tracks the tokens that mark HTML as trusted and that a reference
823+
* reaches through state, the way derived state closures are tracked, so
824+
* the client keeps the server-rendered content of a token until its
825+
* store supplies a value. A token reached through context, or through
826+
* the result of a derived state closure, is not tracked: the client has
827+
* no context store to resolve it from, and the closure is tracked
828+
* instead.
829+
*
830+
* @since 7.2.0
831+
*/
832+
if (
833+
'state' === $path_segments[0] &&
834+
! $called_closure &&
835+
$current instanceof WP_Interactivity_Dangerous_HTML
836+
) {
837+
$this->derived_state_closures[ $ns ] ??= array();
838+
if ( ! in_array( $path, $this->derived_state_closures[ $ns ], true ) ) {
839+
$this->derived_state_closures[ $ns ][] = $path;
840+
}
841+
}
842+
768843
// Returns the opposite if it contains a negation operator (!).
769844
return $should_negate_value ? ! $current : $current;
770845
}
@@ -1414,6 +1489,111 @@ private function data_wp_text_processor( WP_Interactivity_API_Directives_Process
14141489
}
14151490
}
14161491

1492+
/**
1493+
* Processes the `data-wp-html` directive.
1494+
*
1495+
* It sets the content of the current HTML element to the HTML of the token
1496+
* that its associated reference resolves to. The HTML is written as given and
1497+
* the directives inside it are not processed. See
1498+
* {@see wp_interactivity_as_dangerous_html()} for the risks of marking HTML
1499+
* as trusted.
1500+
*
1501+
* The element is left as it would be without the directive, and a notice
1502+
* reports the reason, when the reference does not resolve to a token, the
1503+
* element cannot hold content, or the element also carries `data-wp-text` or,
1504+
* for a `template`, `data-wp-each`. A reference that resolves to `null`
1505+
* declines without a notice. Only the entry without a suffix or a unique ID
1506+
* is processed.
1507+
*
1508+
* @since 7.2.0
1509+
*
1510+
* @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
1511+
* @param string $mode Whether the processing is entering or exiting the tag.
1512+
*/
1513+
private function data_wp_html_processor( WP_Interactivity_API_Directives_Processor $p, string $mode ): void {
1514+
if ( 'enter' !== $mode ) {
1515+
return;
1516+
}
1517+
1518+
// Get the first valid `data-wp-html` entry without suffix or unique ID.
1519+
$valid_entry = array_find(
1520+
$this->get_directive_entries( $p, 'html' ),
1521+
fn( $entry ) => null === $entry['suffix'] && null === $entry['unique_id'] && ! empty( $entry['value'] )
1522+
);
1523+
1524+
if ( null === $valid_entry ) {
1525+
return;
1526+
}
1527+
1528+
/*
1529+
* The structural reasons decline without evaluating the reference, so
1530+
* their notice does not depend on the value and no derived state runs
1531+
* for a result that would be discarded.
1532+
*/
1533+
if ( ! $p->has_and_visits_its_closer_tag() ) {
1534+
_doing_it_wrong(
1535+
__METHOD__,
1536+
sprintf(
1537+
/* translators: %s: The HTML tag name of the element, in lowercase. */
1538+
__( 'The "data-wp-html" directive cannot set the content of a <%s> element because it cannot hold content. The element is left as it is.' ),
1539+
esc_html( strtolower( $p->get_tag() ) )
1540+
),
1541+
'7.2.0'
1542+
);
1543+
return;
1544+
}
1545+
1546+
$combined_directive = null;
1547+
if ( array_find( $this->get_directive_entries( $p, 'text' ), fn( $entry ) => null === $entry['suffix'] && null === $entry['unique_id'] ) ) {
1548+
$combined_directive = 'data-wp-text';
1549+
} elseif ( 'TEMPLATE' === $p->get_tag() && ! empty( $this->get_directive_entries( $p, 'each' ) ) ) {
1550+
$combined_directive = 'data-wp-each';
1551+
}
1552+
if ( null !== $combined_directive ) {
1553+
_doing_it_wrong(
1554+
__METHOD__,
1555+
sprintf(
1556+
/* translators: %s: The directive combined with data-wp-html, such as data-wp-text. */
1557+
__( 'The "data-wp-html" directive cannot be combined with the "%s" directive on the same element. The "data-wp-html" directive is ignored.' ),
1558+
esc_html( $combined_directive )
1559+
),
1560+
'7.2.0'
1561+
);
1562+
return;
1563+
}
1564+
1565+
/*
1566+
* An entry value that is not a string is a JSON literal written in the
1567+
* markup, which no store property can be named by, so it is not evaluated.
1568+
*/
1569+
$result = is_string( $valid_entry['value'] ) ? $this->evaluate( $valid_entry ) : $valid_entry['value'];
1570+
1571+
// Unresolved references and `null` values keep the element as it is, as they do in the client.
1572+
if ( null === $result ) {
1573+
return;
1574+
}
1575+
1576+
$html = WP_Interactivity_Dangerous_HTML::get_html( $result );
1577+
if ( null === $html ) {
1578+
_doing_it_wrong(
1579+
__METHOD__,
1580+
__( 'The "data-wp-html" directive requires a reference that resolves to the value returned by wp_interactivity_as_dangerous_html(). Other values, such as strings of HTML, are not rendered and the element is left as it is.' ),
1581+
'7.2.0'
1582+
);
1583+
return;
1584+
}
1585+
1586+
/*
1587+
* The HTML is held back and a placeholder comment takes its place, so no
1588+
* later step of the call scans it. A comment is a single token for the
1589+
* processors, and the marker in it occurs nowhere in the input.
1590+
*/
1591+
$placeholder = '<!--' . $this->html_placeholder_marker . ':' . count( $this->html_placeholders ) . '-->';
1592+
if ( $p->set_raw_content_between_balanced_tags( $placeholder ) ) {
1593+
$this->html_placeholders[ $placeholder ] = $html;
1594+
}
1595+
}
1596+
14171597
/**
14181598
* Returns the CSS styles for animating the top loading bar in the router.
14191599
*
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
/**
3+
* Interactivity API: WP_Interactivity_Dangerous_HTML class.
4+
*
5+
* @package WordPress
6+
* @subpackage Interactivity API
7+
* @since 7.2.0
8+
*/
9+
10+
/**
11+
* Token that marks an HTML string as trusted, for server directive processing
12+
* to render as the content of an element.
13+
*
14+
* Create tokens with {@see wp_interactivity_as_dangerous_html()}, which
15+
* documents the risks of marking HTML as trusted.
16+
*
17+
* The HTML is kept in a private property, so encoding a token as JSON never
18+
* includes it. A token restored by unserialization is inert: it holds no HTML,
19+
* so it never renders.
20+
*
21+
* @since 7.2.0
22+
*
23+
* @access private
24+
*/
25+
final class WP_Interactivity_Dangerous_HTML {
26+
/**
27+
* The trusted HTML. It is `null` for a token restored by unserialization,
28+
* which never renders.
29+
*
30+
* @since 7.2.0
31+
* @var string|null
32+
*/
33+
private $html = null;
34+
35+
/**
36+
* Constructor.
37+
*
38+
* @since 7.2.0
39+
*
40+
* @param string $html The HTML to mark as trusted. It is not sanitized.
41+
*/
42+
public function __construct( string $html ) {
43+
$this->html = $html;
44+
}
45+
46+
/**
47+
* Gets the trusted HTML of a value.
48+
*
49+
* @since 7.2.0
50+
*
51+
* @param mixed $value Any value.
52+
* @return string|null The HTML of the token if the value is a token whose constructor
53+
* ran, or `null` for any other value.
54+
*/
55+
public static function get_html( $value ): ?string {
56+
return $value instanceof self ? $value->html : null;
57+
}
58+
59+
/**
60+
* Makes a token restored by unserialization inert.
61+
*
62+
* Clears the HTML, so the token never renders, and reports the unserialization
63+
* instead of throwing, so a page that cached a token keeps working.
64+
*
65+
* @since 7.2.0
66+
*/
67+
public function __wakeup(): void {
68+
$this->html = null;
69+
70+
_doing_it_wrong(
71+
__METHOD__,
72+
__( 'A token that marks HTML as trusted should not be unserialized. The unserialized token holds no HTML and does not render.' ),
73+
'7.2.0'
74+
);
75+
}
76+
}

0 commit comments

Comments
 (0)