Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 71 additions & 5 deletions src/wp-includes/html-api/class-wp-html-decoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class WP_HTML_Decoder {
* false === WP_HTML_Decoder::attribute_starts_with( $value, 'https:', 'ascii-case-insensitive' );
*
* @since 6.6.0
* @since 7.1.0 Matches when the search string ends part-way through a decoded
* character reference, and no longer matches when the attribute
* value is shorter than the search string.
*
* @param string $haystack String containing the raw non-decoded attribute value.
* @param string $search_text Does the attribute value start with this plain string.
Expand Down Expand Up @@ -53,24 +56,87 @@ public static function attribute_starts_with( $haystack, $search_text, $case_sen
return false;
}

// If there's no character reference but the character do match, then it could still match.
// If there's no character reference but the characters do match, then it could still match.
if ( null === $next_chunk && $chars_match ) {
++$haystack_at;
++$search_at;
continue;
}

// If there is a character reference, then the decoded value must exactly match what follows in the search string.
if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, strlen( $next_chunk ), $loose_case ) ) {
/**
* A character reference in the haystack decodes into a chunk of one
* or more bytes. The chunk is atomic on the haystack side — decoding
* produces all of it at once and the raw cursor can only skip the
* entire reference — but this is a prefix test, so the search text
* may legitimately end part-way through the chunk. Only the overlapping
* bytes can be compared.
*
* For example, `fj` (7 bytes) decodes into the chunk
* `fj` (2 bytes).
*
* haystack at
* │
* │ ┌─after matching "fj" continue here
* │ │ (+ $token_length / 7 bytes)
* ↓ ↓
* Haystack: startfjord
* ╰──┬──╯
* fj - decoded "fj" character reference chunk
* will be tested against the search text.
*
* search at
* │
* │ ┌─after matching "fj" continue here
* │ │ (+ $match_length / 2 bytes)
* ↓ ↓
* Search A: startfjord min( 2, 5 ) = 2: `fj` matches,
* continue scanning at `o`.
*
* search at
* ↓
* Search B: startf min( 2, 1 ) = 1: `f` matches and
* the search text is exhausted, so
* the prefix is confirmed.
*
* search at
* ↓
* Search C: startfr min( 2, 2 ) = 2: `fj` differs
* from `fr`, no match is possible.
*
* The comparison must be limited to the overlap: the smaller of the chunk
* length and the remaining search text length. Relying exclusively on
* either length leads to false negatives:
*
* // Search A: remaining search text is longer than the decoded chunk.
* // Using length 5 (`$search_length - $search_at`) would cause a false negative:
* substr_compare( 'startfjord', 'fj', 5, 5 ); // non-zero
* // Using length 2 (`strlen( $next_chunk )`) matches correctly:
* substr_compare( 'startfjord', 'fj', 5, 2 ); // 0
*
* // Search B: remaining search text is shorter than the decoded chunk.
* // Using length 2 (`strlen( $next_chunk )`) would cause a false negative:
* substr_compare( 'startf', 'fj', 5, 2 ); // non-zero
* // Using length 1 (`$search_length - $search_at`) matches correctly:
* substr_compare( 'startf', 'fj', 5, 1 ); // 0
*
* After a match, each cursor must advance by its own measure — the raw
* reference and its decoded chunk have unrelated lengths (7 and 2 above):
* `$haystack_at` skips the whole raw reference (`$token_length`) while
* `$search_at` advances only by the decoded bytes matched (`$match_length`).
* A match that exhausts the search text (Search B) ends the loop with
* `$search_at === $search_length`, which the final return reports as success.
*/
$match_length = min( strlen( $next_chunk ), $search_length - $search_at );
if ( 0 !== substr_compare( $search_text, $next_chunk, $search_at, $match_length, $loose_case ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

would this logic be clearer and still held if, instead of adjusting numbers here, that we cut the $next_chunk short if if the prefix length plus its own length is beyond the search/needle length?

that is, if we have fi as the replacement, but the search length is one less than that, we would simply $next_chunk = $length_okay ? $next_chunk : substr( $next_chunk, 0, $max_length )?

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.

I explored something like that and I don't think it's clearer overall. The explanation seems sufficient.

return false;
}

// The character reference matched, so continue checking.
$haystack_at += $token_length;
$search_at += strlen( $next_chunk );
$search_at += $match_length;
}

return true;
return $search_at === $search_length;
}

/**
Expand Down
113 changes: 113 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlDecoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,119 @@ public static function data_case_variants_of_attribute_prefixes() {
}
}

/**
* Ensures that `attribute_starts_with` checks the full search string.
*
* @ticket 65372
*
* @dataProvider data_attribute_starts_with_search_string_boundaries
*
* @param string $attribute_value Raw attribute value from HTML string.
* @param string $search_string Prefix contained or not contained in encoded attribute value.
* @param string $case_sensitivity Whether to search with ASCII case sensitivity;
* 'ascii-case-insensitive' or 'case-sensitive'.
* @param bool $is_match Whether the search string is a prefix for the attribute value.
*/
public function test_attribute_starts_with_checks_search_string_boundaries(
string $attribute_value,
string $search_string,
string $case_sensitivity,
bool $is_match
): void {
if ( $is_match ) {
$this->assertTrue(
WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ),
'Should have matched attribute prefix.'
);
} else {
$this->assertFalse(
WP_HTML_Decoder::attribute_starts_with( $attribute_value, $search_string, $case_sensitivity ),
'Should not have matched attribute with prefix.'
);
}
}

/**
* Data provider.
*
* @return Generator<string, array{string, string, string, bool}> Test cases.
*/
public static function data_attribute_starts_with_search_string_boundaries(): Generator {
yield 'Empty attribute does not match non-empty prefix' => array( '', 'http', 'case-sensitive', false );
yield 'Short attribute does not match longer prefix' => array(
'java',
'javascript',
'case-sensitive',
false,
);
yield 'Attribute ending in a character reference does not match a longer prefix' => array(
'&amp;',
'&&',
'case-sensitive',
false,
);
yield 'Longer attribute matches shorter prefix' => array(
'javascript',
'java',
'case-sensitive',
true,
);
yield "&fjlig; (decodes to 2-codepoint 'fj') starts with f" => array(
'&fjlig; is literally "f" followed by "j"',
'f',
'case-sensitive',
true,
);
yield "&nvlt; (decodes to 2-codepoint '<⃒') starts with '<'" => array(
'&nvlt;script>',
'<',
'case-sensitive',
true,
);
yield "Combining character references (¬̸) full match on '¬̸' prefix" => array(
'&not;&#x338; A negated not?',
'¬̸',
'case-sensitive',
true,
);
yield "Combining character references (¬̸) partial match on '¬' prefix" => array(
'&not;&#x338; A negated not?',
'¬',
'case-sensitive',
true,
);
yield 'Search A: prefix continues past a decoded character reference' => array(
'start&fjlig;ord',
'startfjord',
'case-sensitive',
true,
);
yield 'Search B: prefix ends part-way through a decoded character reference' => array(
'start&fjlig;ord',
'startf',
'case-sensitive',
true,
);
yield 'Search C: prefix mismatches within a decoded character reference' => array(
'start&fjlig;ord',
'startfr',
'case-sensitive',
false,
);
yield 'ASCII-case-insensitive prefix ends part-way through a decoded character reference' => array(
'start&fjlig;ord',
'STARTF',
'ascii-case-insensitive',
true,
);
yield 'ASCII-case-insensitive prefix mismatches within a decoded character reference' => array(
'start&fjlig;ord',
'STARTFR',
'ascii-case-insensitive',
false,
);
}

/**
* Ensures that `attribute_starts_with` respects the case sensitivity argument.
*
Expand Down
Loading