Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
cee0661
HTML API: Add tests for attribute value input preprocessing.
sirreal Jun 11, 2026
82a26aa
HTML API: Apply input preprocessing in get_attribute().
sirreal Jun 11, 2026
48d8fb4
HTML API: Add tests for class updates over preprocessed values.
sirreal Jun 11, 2026
d1f852c
HTML API: Apply input preprocessing when flushing class updates.
sirreal Jun 11, 2026
020155a
HTML API: Add tests for NULL bytes in attribute names.
sirreal Jun 11, 2026
442e820
HTML API: Replace NULL bytes in comparable attribute names.
sirreal Jun 11, 2026
135157f
HTML API: Add tests for NULL bytes in tag names.
sirreal Jun 11, 2026
5b8ad27
HTML API: Replace NULL bytes in tag names at the read boundary.
sirreal Jun 11, 2026
f6f58fd
HTML API: Add test for NULL bytes in API-supplied class values.
sirreal Jun 11, 2026
ba93ef4
HTML API: Stop replacing NULL bytes in API-supplied class values.
sirreal Jun 11, 2026
9baceb6
HTML API: Avoid re-scanning attribute values without CR or NULL bytes.
sirreal Jun 11, 2026
3b415d1
HTML API: Add tests for character references preceding replaced bytes.
sirreal Jun 11, 2026
8f5e8b2
HTML API: Replace NULL bytes after decoding attribute values.
sirreal Jun 11, 2026
e18f389
HTML API: Detect ambiguous character reference followers by ASCII only.
sirreal Jun 11, 2026
449bf72
HTML API: Add tests for tag-name queries over replaced names.
sirreal Jun 11, 2026
5c52634
HTML API: Match tag-name queries against replaced names.
sirreal Jun 11, 2026
5292c7d
HTML API: Add test for case-insensitive class update flushing.
sirreal Jun 11, 2026
8c26adf
HTML API: Flush class updates for any case spelling of "class".
sirreal Jun 11, 2026
e41d168
HTML API: Pin edge cases of replaced names and document boundaries.
sirreal Jun 11, 2026
78d58d0
HTML API: Add tests for serializing decoded carriage returns.
sirreal Jun 11, 2026
4127e36
HTML API: Serialize decoded carriage returns as character references.
sirreal Jun 11, 2026
9c3302f
HTML API: Pin serialization of NULL bytes in API-supplied values.
sirreal Jun 11, 2026
96c6fb8
HTML API: Consolidate serializer NULL-byte handling.
sirreal Jun 11, 2026
3a74497
HTML API: Pin rawtext serialization and reparse round trips.
sirreal Jun 11, 2026
11967d9
Merge remote-tracking branch 'upstream/trunk' into HEAD
sirreal Jun 15, 2026
88a4d52
Merge remote-tracking branch 'upstream/trunk' into HEAD
sirreal Jun 15, 2026
7f64468
Merge branch 'trunk' into spec-compliant-getters
sirreal Jul 1, 2026
62d682f
Revert irrelevant doc changes
sirreal Jul 1, 2026
b7d4b39
Remove redundant tests
sirreal Jul 1, 2026
30b17da
Remove excessive docs
sirreal Jul 1, 2026
7e451fe
Simplify tag name matching logic
sirreal Jul 1, 2026
b3d15f5
Remove excessive documentation
sirreal Jul 1, 2026
624fe63
simplify comment
sirreal Jul 1, 2026
5293caa
Remove excessive documentation
sirreal Jul 1, 2026
7f77e93
Simplify attribute value getter
sirreal Jul 1, 2026
908f4b3
Ignore new private method
sirreal Jul 1, 2026
fed8e18
Rework new function docs
sirreal Jul 1, 2026
3c61f03
Remove excessive docs
sirreal Jul 1, 2026
480bce8
Reformat comment
sirreal Jul 1, 2026
d701662
Test function types
sirreal Jul 1, 2026
0727c27
Merge branch 'spec-compliant-getters' into html-api-fuzz-fiz/decoded-cr
sirreal Jul 1, 2026
9eda861
Merge branch 'trunk' into html-api-fuzz-fiz/decoded-cr
sirreal Jul 9, 2026
df36b29
Use hex numeric character reference
sirreal Jul 10, 2026
a0ebe96
Merge branch 'trunk' into html-api-fuzz-fiz/decoded-cr
sirreal Jul 10, 2026
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
35 changes: 28 additions & 7 deletions src/wp-includes/html-api/class-wp-html-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -1387,7 +1387,7 @@ public function serialize_token(): string {
break;

case '#text':
$html .= htmlspecialchars( $this->get_modifiable_text(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' );
$html .= self::serialize_decoded_text( $this->get_modifiable_text() );
break;

// Unlike the `<>` which is interpreted as plaintext, this is ignored entirely.
Expand Down Expand Up @@ -1417,10 +1417,9 @@ public function serialize_token(): string {
return $html;
}

$tag_name = str_replace( "\x00", "\u{FFFD}", $this->get_tag() );
$tag_name = $this->get_tag();
$in_html = 'html' === $this->get_namespace();
$qualified_name = $in_html ? strtolower( $tag_name ) : $this->get_qualified_tag_name();
Comment on lines +1420 to 1422

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are already handled since r62667:

$tag_name = str_replace( "\x00", "\u{FFFD}", substr( $this->html, $this->tag_name_starts_at, $this->tag_name_length ) );

$qualified_name = str_replace( "\x00", "\u{FFFD}", $qualified_name );

if ( $this->is_tag_closer() ) {
$html .= "</{$qualified_name}>";
Expand All @@ -1439,7 +1438,6 @@ public function serialize_token(): string {
$seen_attribute_names = array();
foreach ( $attribute_names as $attribute_name ) {
$qualified_attribute_name = $this->get_qualified_attribute_name( $attribute_name );
$qualified_attribute_name = str_replace( "\x00", "\u{FFFD}", $qualified_attribute_name );
$qualified_attribute_name = wp_scrub_utf8( $qualified_attribute_name );
/**
* Spaces only appear via the foreign attribute adjustment table.
Expand All @@ -1464,11 +1462,10 @@ public function serialize_token(): string {
$value = $this->get_attribute( $attribute_name );

if ( is_string( $value ) ) {
$html .= '="' . htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5 ) . '"';
$html .= '="' . self::serialize_decoded_text( $value ) . '"';
}

$previous_attribute_was_true = true === $value;
$html = str_replace( "\x00", "\u{FFFD}", $html );
}

if ( ! $in_html && $this->has_self_closing_flag() ) {
Expand Down Expand Up @@ -1520,7 +1517,7 @@ public function serialize_token(): string {
break;

default:
$text = htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' );
$text = self::serialize_decoded_text( $text );
}

$html .= "{$text}</{$qualified_name}>";
Expand All @@ -1529,6 +1526,30 @@ public function serialize_token(): string {
return $html;
}

/**
* Serializes decoded text for use in text nodes and attribute values.
*
* A decoded carriage return must serialize as a character reference:
* the HTML parser's input preprocessing turns a raw CR into a line
* feed, so emitting it raw would change the text on the next parse
* and serialized output would never reach a fixed point.
*
* NULL bytes, possible in API-supplied values, serialize as U+FFFD
* for the same reason: the tokenizer would replace or remove a raw
* NULL byte on the next parse.
*
* @since 7.1.0
*
* @param string $text Decoded text to serialize.
* @return string Serialized text.
*/
private static function serialize_decoded_text( string $text ): string {
$text = htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' );
$text = str_replace( "\r", '&#xD;', $text );

return str_replace( "\x00", "\u{FFFD}", $text );
}

/**
* Parses next element in the 'initial' insertion mode.
*
Expand Down
155 changes: 155 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,161 @@ public static function data_provider_normalized_fuzzer_cases_that_should_be_idem
);
}

/**
* Ensures that decoded carriage returns are serialized as character references.
*
* @ticket 65372
*
* @dataProvider data_provider_decoded_carriage_returns
*
* @param string $input HTML input containing a decoded carriage return.
* @param string $expected Expected normalized output.
*/
public function test_normalize_serializes_decoded_carriage_returns_as_character_references( string $input, string $expected ) {
$normalized = WP_HTML_Processor::normalize( $input );

$this->assertSame( $expected, $normalized, 'Should have serialized the carriage return as a character reference.' );
$this->assertSame(
$expected,
WP_HTML_Processor::normalize( $normalized ),
'Normalizing already-normalized HTML should not change the serialized carriage return.'
);
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_provider_decoded_carriage_returns() {
return array(
'Regular text' => array( '<p>a&#13;b</p>', '<p>a&#13;b</p>' ),
'Regular text with non-canonical character reference' => array( '<p>a&#x0D;b</p>', '<p>a&#13;b</p>' ),
'RCDATA title' => array( '<title>a&#13;b</title>', '<title>a&#13;b</title>' ),
'RCDATA textarea with leading-newline preservation' => array( '<textarea>a&#13;b</textarea>', "<textarea>\na&#13;b</textarea>" ),
'Attribute value' => array( '<p title="a&#13;b"></p>', '<p title="a&#13;b"></p>' ),
'Table text' => array( '<table><tr><td>x&#13;</td></tr></table>', '<table><tbody><tr><td>x&#13;</td></tr></tbody></table>' ),
'Template text' => array( '<template><p>a&#13;b</p></template>', '<template><p>a&#13;b</p></template>' ),
);
}

/**
* Ensures that raw carriage returns in attribute values are serialized as line feeds.
*
* @ticket 65372
*
* @dataProvider data_provider_raw_attribute_carriage_returns
*
* @param string $input HTML input containing raw carriage returns.
* @param string $expected Expected normalized output.
*/
public function test_normalize_serializes_raw_attribute_carriage_returns_as_line_feeds( string $input, string $expected ) {
$normalized = WP_HTML_Processor::normalize( $input );

$this->assertSame( $expected, $normalized, 'Should have serialized raw attribute carriage returns as line feeds.' );
$this->assertSame(
$expected,
WP_HTML_Processor::normalize( $normalized ),
'Normalizing already-normalized HTML should not change raw attribute newlines.'
);
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_provider_raw_attribute_carriage_returns() {
return array(
'Raw carriage return' => array( "<p title=\"a\rb\"></p>", "<p title=\"a\nb\"></p>" ),
'Raw CRLF pair' => array( "<p title=\"a\r\nb\"></p>", "<p title=\"a\nb\"></p>" ),
);
}

/**
* Ensures that raw carriage returns are normalized before class updates are serialized.
*
* @ticket 65372
*/
public function test_serialize_token_normalizes_raw_class_carriage_returns_before_class_updates() {
$processor = WP_HTML_Processor::create_fragment( "<p class=\"a\rb\"></p>" );

$this->assertTrue( $processor->next_tag( 'P' ), 'Should find the P element.' );

$processor->add_class( 'c' );

$serialized = $processor->serialize_token();
$this->assertSame(
"<p class=\"a\nb c\">",
$serialized,
'Should have serialized raw class carriage returns as line feeds before adding classes.'
);

$reparsed = WP_HTML_Processor::create_fragment( $serialized );
$this->assertTrue( $reparsed->next_tag( 'P' ), 'Should find the reparsed P element.' );
$this->assertSame( "a\nb c", $reparsed->get_attribute( 'class' ), 'The serialized class should parse back to the same value.' );
}

/**
* Ensures rawtext element contents serialize without escaping:
* character references do not decode inside SCRIPT and STYLE, so
* escaping their contents or emitting `&#13;` there would corrupt them.
*
* @ticket 65372
*
* @dataProvider data_provider_rawtext_contents
*
* @param string $html HTML whose rawtext contents must serialize unchanged.
*/
public function test_normalize_preserves_rawtext_contents( string $html ) {
$this->assertSame(
$html,
WP_HTML_Processor::normalize( $html ),
'Should have serialized the rawtext contents unchanged.'
);
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_provider_rawtext_contents() {
return array(
'SCRIPT with character references' => array( '<script>a&#13;&amp;b</script>' ),
'STYLE with character references' => array( '<style>a&#13;&amp;b</style>' ),
);
}

/**
* Ensures NULL bytes in attribute values set through the API serialize
* as U+FFFD so that serialized output parses back to the same value.
*
* Browsers serialize the raw NULL byte in innerHTML, which does not
* round-trip: re-parsing replaces it with U+FFFD. Serializing U+FFFD
* directly is a benign deviation which keeps output idempotent, like
* serializing decoded carriage returns as &#13;.
*
* @ticket 65372
*/
public function test_serialize_token_replaces_null_bytes_in_enqueued_attribute_values() {
$processor = WP_HTML_Processor::create_fragment( '<p title="x"></p>' );

$this->assertTrue( $processor->next_tag( 'P' ), 'Should find the P element.' );
$this->assertTrue( $processor->set_attribute( 'title', "a\x00b" ), 'Should have set the attribute.' );

$serialized = $processor->serialize_token();
$this->assertSame(
"<p title=\"a\u{FFFD}b\">",
$serialized,
'Should have serialized the NULL byte as U+FFFD.'
);

$reparsed = WP_HTML_Processor::create_fragment( $serialized );
$this->assertTrue( $reparsed->next_tag( 'P' ), 'Should find the reparsed P element.' );
$this->assertSame( "a\u{FFFD}b", $reparsed->get_attribute( 'title' ), 'The serialized title should parse back to the same value.' );
}

/**
* Data provider.
*
Expand Down
Loading