Skip to content

Commit f837c75

Browse files
committed
Add attribute placeholder extraction with context promotion
Extract placeholders from attribute values using regex. When a placeholder appears in both text and attribute contexts, promote to attribute context (more restrictive escaping applies everywhere).
1 parent 7ee7ec4 commit f837c75

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,56 @@ public function get_tag_attributes(): array {
109109

110110
$this->compiled[ $placeholder ]['offsets'][] = array( $start, $length );
111111
break;
112+
113+
case '#tag':
114+
if ( $processor->is_tag_closer() ) {
115+
break;
116+
}
117+
118+
$html = $processor->get_html();
119+
foreach ( $processor->get_tag_attributes() as $attribute ) {
120+
// Boolean attributes cannot contain placeholders.
121+
if ( $attribute->is_true ) {
122+
continue;
123+
}
124+
// At least `</%x>` to contain a placeholder.
125+
if ( $attribute->value_length < 5 ) {
126+
continue;
127+
}
128+
129+
$offset = $attribute->value_starts_at;
130+
$end = $offset + $attribute->value_length;
131+
132+
while (
133+
1 === preg_match(
134+
'#</%[ \\t\\r\\f\\n]*([a-z0-9_-]+)[ \\t\\r\\f\\n]*>#i',
135+
$html,
136+
$matches,
137+
PREG_OFFSET_CAPTURE,
138+
$offset
139+
)
140+
&& $matches[0][1] < $end
141+
) {
142+
$placeholder = $matches[1][0];
143+
$match_start = $matches[0][1];
144+
$match_length = strlen( $matches[0][0] );
145+
146+
if ( ! isset( $this->compiled[ $placeholder ] ) ) {
147+
$this->compiled[ $placeholder ] = array(
148+
'offsets' => array(),
149+
'context' => 'attribute',
150+
);
151+
} else {
152+
// Promote text context to attribute context.
153+
$this->compiled[ $placeholder ]['context'] = 'attribute';
154+
}
155+
156+
$this->compiled[ $placeholder ]['offsets'][] = array( $match_start, $match_length );
157+
158+
$offset = $match_start + $match_length;
159+
}
160+
}
161+
break;
112162
}
113163
}
114164
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,4 +809,43 @@ public function test_extracts_repeated_text_placeholders() {
809809
$this->assertArrayHasKey( 'name', $placeholders );
810810
$this->assertCount( 2, $placeholders['name']['offsets'] );
811811
}
812+
813+
/**
814+
* Verifies attribute placeholders are extracted.
815+
*
816+
* @ticket 60229
817+
*
818+
* @covers ::get_placeholders
819+
*/
820+
public function test_extracts_attribute_placeholders() {
821+
$template = T::from( '<meta name="</%n>" content="</%c>">' );
822+
823+
$placeholders = $template->get_placeholders();
824+
825+
$this->assertArrayHasKey( 'n', $placeholders );
826+
$this->assertArrayHasKey( 'c', $placeholders );
827+
$this->assertSame( 'attribute', $placeholders['n']['context'] );
828+
$this->assertSame( 'attribute', $placeholders['c']['context'] );
829+
}
830+
831+
/**
832+
* Verifies context promotion from text to attribute.
833+
*
834+
* When a placeholder appears in both text and attribute contexts,
835+
* the attribute context takes precedence (more restrictive escaping).
836+
*
837+
* @ticket 60229
838+
*
839+
* @covers ::get_placeholders
840+
*/
841+
public function test_context_promotion_text_to_attribute() {
842+
$template = T::from( '<a href="</%url>"></%url></a>' );
843+
844+
$placeholders = $template->get_placeholders();
845+
846+
$this->assertArrayHasKey( 'url', $placeholders );
847+
// Both occurrences should use attribute context
848+
$this->assertSame( 'attribute', $placeholders['url']['context'] );
849+
$this->assertCount( 2, $placeholders['url']['offsets'] );
850+
}
812851
}

0 commit comments

Comments
 (0)