Skip to content

Commit 54d85bc

Browse files
committed
Fix attribute text escaping and add trailing segment handling
Use WP_HTML_Decoder::decode_attribute to decode static attribute text before re-encoding, preventing double-escaping of existing character references (e.g. & staying & instead of becoming &). Also track trailing text segments after the last placeholder within attribute values, not just leading/inter-placeholder segments.
1 parent 0808317 commit 54d85bc

2 files changed

Lines changed: 53 additions & 2 deletions

File tree

src/wp-includes/html-api/class-wp-html-template.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ public function get_tag_attributes(): array {
211211
$last_offset = $match_start + $match_length;
212212
$offset = $last_offset;
213213
}
214+
215+
// Track trailing text segment after last placeholder.
216+
if ( $last_offset < $end ) {
217+
$this->attr_escapes[] = array( $last_offset, $end - $last_offset );
218+
}
214219
}
215220
break;
216221
}
@@ -426,9 +431,14 @@ public function render(): string|false {
426431
}
427432

428433
// 3. Attribute text escaping.
434+
// Static text in attribute values needs escaping to prevent character
435+
// reference injection (e.g. "&" + "not" = "&not;" = "¬"). Decode
436+
// existing character references first, then re-encode to avoid
437+
// double-escaping (e.g. "&amp;" should stay "&amp;", not become "&amp;amp;").
429438
foreach ( $this->attr_escapes as list( $start, $length ) ) {
430-
$original = substr( $html, $start, $length );
431-
$updates[] = array( $start, $length, strtr( $original, $escape_map ) );
439+
$original = substr( $html, $start, $length );
440+
$decoded = WP_HTML_Decoder::decode_attribute( $original );
441+
$updates[] = array( $start, $length, strtr( $decoded, $escape_map ) );
432442
}
433443

434444
// Sort by start position descending so replacements don't shift positions.

tests/phpunit/tests/html-api/wpHtmlTemplate.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,47 @@ public function test_extracts_attribute_placeholders() {
882882
*
883883
* @covers ::get_placeholders
884884
*/
885+
/**
886+
* Verifies that static text around placeholders in attributes is escaped.
887+
*
888+
* @ticket 60229
889+
*
890+
* @covers ::from
891+
* @covers ::bind
892+
* @covers ::render
893+
*/
894+
public function test_escapes_static_text_around_placeholder_in_attribute() {
895+
// Leading static text (prefix before placeholder)
896+
$result = T::from( '<a href="/path/</%slug>">Link</a>' )
897+
->bind( array( 'slug' => 'hello' ) )
898+
->render();
899+
$this->assertEqualHTML( '<a href="/path/hello">Link</a>', $result );
900+
901+
// Trailing static text (suffix after placeholder)
902+
$result = T::from( '<a href="</%slug>/page">Link</a>' )
903+
->bind( array( 'slug' => 'hello' ) )
904+
->render();
905+
$this->assertEqualHTML( '<a href="hello/page">Link</a>', $result );
906+
907+
// Ampersand in trailing static text must be escaped
908+
$result = T::from( '<a href="</%base>&amp;extra=1">Link</a>' )
909+
->bind( array( 'base' => '/search?q=test' ) )
910+
->render();
911+
$this->assertEqualHTML( '<a href="/search?q=test&amp;extra=1">Link</a>', $result );
912+
913+
// Ampersand entity in leading static text must not be double-escaped
914+
$result = T::from( '<a href="/search?a=1&amp;b=</%val>">Link</a>' )
915+
->bind( array( 'val' => '2' ) )
916+
->render();
917+
$this->assertEqualHTML( '<a href="/search?a=1&amp;b=2">Link</a>', $result );
918+
919+
// Character reference in trailing static text is preserved (not double-escaped)
920+
$result = T::from( '<meta name="</%placeholder>&not;">' )
921+
->bind( array( 'placeholder' => '' ) )
922+
->render();
923+
$this->assertEqualHTML( '<meta name="¬">', $result );
924+
}
925+
885926
public function test_context_promotion_text_to_attribute() {
886927
$template = T::from( '<a href="</%url>"></%url></a>' );
887928

0 commit comments

Comments
 (0)