Skip to content
Open
8 changes: 8 additions & 0 deletions src/wp-includes/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -1983,6 +1983,14 @@ function wp_kses_check_attr_val( $value, $vless, $checkname, $checkvalue ) {
function wp_kses_bad_protocol( $content, $allowed_protocols ) {
$content = wp_kses_no_null( $content );

// Preserve relative text fragment URLs when web protocols are allowed.
if (
( in_array( 'http', $allowed_protocols, true ) || in_array( 'https', $allowed_protocols, true ) ) &&
preg_match( '/^[^:&]*#:~:text=/', $content )

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Allows & (e.g. a query string) before the fragment while still rejecting &#… and :, which can decode to a colon:

Suggested change
preg_match( '/^[^:&]*#:~:text=/', $content )
preg_match( '/^(?:[^:&]|&(?!#|colon;))*#:~:text=/i', $content )

) {
return $content;
}

// Short-circuit if the string starts with `https://` or `http://`. Most common cases.
if (
( str_starts_with( $content, 'https://' ) && in_array( 'https', $allowed_protocols, true ) ) ||
Expand Down
118 changes: 118 additions & 0 deletions tests/phpunit/tests/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -2910,4 +2910,122 @@ public function data_allowed_attributes_in_descriptions() {
),
);
}

/**
* Tests sanitization of text fragment URLs and ordinary anchors.
*
* @ticket 60347
* @dataProvider data_wp_kses_text_fragments
*
* @param string $url URL to sanitize.
* @param string $expected Expected sanitized URL.
* @param string[] $allowed_protocols Allowed URL protocols.
*/
public function test_wp_kses_text_fragments( $url, $expected, $allowed_protocols ) {
$allowed_html = array(
'a' => array(
'href' => true,
),
);

$this->assertEqualHTML(
'<a href="' . $expected . '">Link</a>',
wp_kses( '<a href="' . $url . '">Link</a>', $allowed_html, $allowed_protocols )
);
}
Comment on lines +2914 to +2935

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.

Let's refactor this to use a data provider. The data provider should include cases for:

  1. #:~:text=highlight
  2. /absolute/path/#:~:text=highlight
  3. https://example.com/#:~:text=highlight

Maybe others.

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.

Also, it should check for non-text fragment anchor links as well, like #footer.


/**
* Data provider for test_wp_kses_text_fragments().
*
* @return array[]
*/
public function data_wp_kses_text_fragments() {
return array(
'fragment only' => array(
'#:~:text=highlight',
'#:~:text=highlight',
array( 'http', 'https' ),
),
'absolute path' => array(
'/absolute/path/#:~:text=highlight',
'/absolute/path/#:~:text=highlight',
array( 'http', 'https' ),
),
'relative path' => array(
'relative/path/#:~:text=highlight',
'relative/path/#:~:text=highlight',
array( 'https' ),
),
'HTTPS URL' => array(
'https://example.com/#:~:text=highlight',
'https://example.com/#:~:text=highlight',
array( 'https' ),
),
'HTTP URL' => array(
'http://example.com/#:~:text=highlight',
'http://example.com/#:~:text=highlight',
array( 'http' ),
),
'ordinary anchor' => array(
'#footer',
'#footer',
array( 'http', 'https' ),
),
'HTTP only' => array(
'#:~:text=highlight',
'#:~:text=highlight',
array( 'http' ),
),
'HTTPS only' => array(
'#:~:text=highlight',
'#:~:text=highlight',
array( 'https' ),
),
'mailto only' => array(
'#:~:text=highlight',
'text=highlight',
array( 'mailto' ),
),
'default protocols' => array(
'#:~:text=highlight',
'#:~:text=highlight',
array(),
),
'restricted absolute path' => array(
'/absolute/path/#:~:text=highlight',
'text=highlight',
array( 'mailto' ),
),
'JavaScript URL' => array(
'javascript:alert(1)',
'alert(1)',
array( 'http', 'https' ),
),
'JavaScript with text fragment' => array(
'javascript:alert(1)#:~:text=highlight',
'text=highlight',
array( 'http', 'https' ),
),
'encoded JavaScript colon' => array(
'javascript&#58;alert(1)#:~:text=highlight',
'text=highlight',
array( 'http', 'https' ),
),
'text containing colon' => array(
'#:~:text=javascript:example',
'#:~:text=javascript:example',
array( 'https' ),
),
);
}

/**
* Tests that text fragments respect explicit protocol restrictions.
*
* @ticket 60347
*/
public function test_wp_kses_bad_protocol_restricts_text_fragments() {
$this->assertSame( 'text=highlight', wp_kses_bad_protocol( '#:~:text=highlight', array( 'mailto' ) ) );
$this->assertSame( 'text=highlight', wp_kses_bad_protocol( '#:~:text=highlight', array() ) );
}
}
Loading