Skip to content
Draft
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
96 changes: 28 additions & 68 deletions src/wp-includes/formatting.php
Original file line number Diff line number Diff line change
Expand Up @@ -3306,89 +3306,49 @@ static function( $matches ) {
*
* @since 5.1.0
* @since 5.6.0 Removed 'noreferrer' relationship.
* @since 6.3.0 Rely on the Tag Processor for HTML searching and modification.
*
* @param string $text Content that may contain HTML A elements.
* @return string Converted content.
*/
function wp_targeted_link_rel( $text ) {
// Don't run (more expensive) regex if no links with targets.
// Don't run (more expensive) code if no links with targets are possible.
if ( stripos( $text, 'target' ) === false || stripos( $text, '<a ' ) === false || is_serialized( $text ) ) {
return $text;
}

$script_and_style_regex = '/<(script|style).*?<\/\\1>/si';

preg_match_all( $script_and_style_regex, $text, $matches );
$extra_parts = $matches[0];
$html_parts = preg_split( $script_and_style_regex, $text );

foreach ( $html_parts as &$part ) {
$part = preg_replace_callback( '|<a\s([^>]*target\s*=[^>]*)>|i', 'wp_targeted_link_rel_callback', $part );
}

$text = '';
for ( $i = 0; $i < count( $html_parts ); $i++ ) {
$text .= $html_parts[ $i ];
if ( isset( $extra_parts[ $i ] ) ) {
$text .= $extra_parts[ $i ];
$p = new WP_HTML_Tag_Processor( $text );
while ( $p->next_tag( 'a' ) ) {
if ( ! is_string( $p->get_attribute( 'target' ) ) ) {
continue;
}
}

return $text;
}
$href = $p->get_attribute( 'href' );
$rel = $p->get_attribute( 'rel' );
$rel = is_string( $rel ) ? $rel : '';
$link_text = sprintf( 'href="%s" rel="%s"', esc_attr( $href ), esc_attr( $rel ) );

/**
* Callback to add `rel="noopener"` string to HTML A element.
*
* Will not duplicate an existing 'noopener' value to avoid invalidating the HTML.
*
* @since 5.1.0
* @since 5.6.0 Removed 'noreferrer' relationship.
*
* @param array $matches Single match.
* @return string HTML A Element with `rel="noopener"` in addition to any existing values.
*/
function wp_targeted_link_rel_callback( $matches ) {
$link_html = $matches[1];
$original_link_html = $link_html;

// Consider the HTML escaped if there are no unescaped quotes.
$is_escaped = ! preg_match( '/(^|[^\\\\])[\'"]/', $link_html );
if ( $is_escaped ) {
// Replace only the quotes so that they are parsable by wp_kses_hair(), leave the rest as is.
$link_html = preg_replace( '/\\\\([\'"])/', '$1', $link_html );
}

$atts = wp_kses_hair( $link_html, wp_allowed_protocols() );

/**
* Filters the rel values that are added to links with `target` attribute.
*
* @since 5.1.0
*
* @param string $rel The rel values.
* @param string $link_html The matched content of the link tag including all HTML attributes.
*/
$rel = apply_filters( 'wp_targeted_link_rel', 'noopener', $link_html );

// Return early if no rel values to be added or if no actual target attribute.
if ( ! $rel || ! isset( $atts['target'] ) ) {
return "<a $original_link_html>";
}

if ( isset( $atts['rel'] ) ) {
$all_parts = preg_split( '/\s/', "{$atts['rel']['value']} $rel", -1, PREG_SPLIT_NO_EMPTY );
$rel = implode( ' ', array_unique( $all_parts ) );
}

$atts['rel']['whole'] = 'rel="' . esc_attr( $rel ) . '"';
$link_html = implode( ' ', array_column( $atts, 'whole' ) );
/**
* Filters the rel values that are added to links with `target` attribute.
*
* @TODO: Some plugins scan the link text, though they mostly scan for URL patterns.
* We need to provide the existing tag HTML or find a way to update filters.
*
* @since 5.1.0
*
* @param string $rel The rel values.
* @param string $link_html The matched content of the link tag including all HTML attributes.
*/
$updated_rel = apply_filters( 'wp_targeted_link_rel', 'noopener', $link_text );
if ( ! $updated_rel ) {
continue;
}

if ( $is_escaped ) {
$link_html = preg_replace( '/[\'"]/', '\\\\$0', $link_html );
$all_rel_parts = preg_split( '/\s/', "$rel $updated_rel", -1, PREG_SPLIT_NO_EMPTY );
$p->set_attribute( 'rel', implode( ' ', array_unique( $all_rel_parts ) ) );
}

return "<a $link_html>";
return $p->get_updated_html();
}

/**
Expand Down
30 changes: 15 additions & 15 deletions tests/phpunit/tests/formatting/wpTargetedLinkRel.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,25 @@ class Tests_Formatting_wpTargetedLinkRel extends WP_UnitTestCase {
public function test_add_to_links_with_target_blank() {
$content = '<p>Links: <a href="/" target="_blank">No rel</a></p>';
$expected = '<p>Links: <a href="/" target="_blank" rel="noopener">No rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_add_to_links_with_target_foo() {
$content = '<p>Links: <a href="/" target="foo">No rel</a></p>';
$expected = '<p>Links: <a href="/" target="foo" rel="noopener">No rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_target_as_first_attribute() {
$content = '<p>Links: <a target="_blank" href="#">No rel</a></p>';
$expected = '<p>Links: <a target="_blank" href="#" rel="noopener">No rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_add_to_existing_rel() {
$content = '<p>Links: <a href="/" rel="existing values" target="_blank">Existing rel</a></p>';
$expected = '<p>Links: <a href="/" rel="existing values noopener" target="_blank">Existing rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_no_duplicate_values_added() {
Expand All @@ -41,31 +41,31 @@ public function test_no_duplicate_values_added() {
public function test_rel_with_single_quote_delimiter() {
$content = '<p>Links: <a href="/" rel=\'existing values\' target="_blank">Existing rel</a></p>';
$expected = '<p>Links: <a href="/" rel="existing values noopener" target="_blank">Existing rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_rel_with_no_delimiter() {
$content = '<p>Links: <a href="/" rel=existing target="_blank">Existing rel</a></p>';
$expected = '<p>Links: <a href="/" rel="existing noopener" target="_blank">Existing rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_rel_value_spaced_and_no_delimiter() {
$content = '<p>Links: <a href="/" rel = existing target="_blank">Existing rel</a></p>';
$expected = '<p>Links: <a href="/" rel="existing noopener" target="_blank">Existing rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_escaped_quotes() {
$content = '<p>Links: <a href=\"/\" rel=\"existing values\" target=\"_blank\">Existing rel</a></p>';
$expected = '<p>Links: <a href=\"/\" rel=\"existing values noopener\" target=\"_blank\">Existing rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_ignore_links_with_no_target() {
$content = '<p>Links: <a href="/" target="_blank">Change me</a> <a href="/">Do not change me</a></p>';
$expected = '<p>Links: <a href="/" target="_blank" rel="noopener">Change me</a> <a href="/">Do not change me</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

/**
Expand All @@ -77,7 +77,7 @@ public function test_ignore_if_wp_targeted_link_rel_nulled() {
add_filter( 'wp_targeted_link_rel', '__return_empty_string' );
$content = '<p>Links: <a href="/" target="_blank">Do not change me</a></p>';
$expected = '<p>Links: <a href="/" target="_blank">Do not change me</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

/**
Expand All @@ -95,7 +95,7 @@ public function test_wp_targeted_link_rel_filters_run() {
)
);

$this->assertSame( $expected, $post->post_content );
$this->assertEqualMarkup( $expected, $post->post_content );
}

/**
Expand All @@ -106,7 +106,7 @@ public function test_wp_targeted_link_rel_filters_run() {
public function test_wp_targeted_link_rel_should_preserve_json() {
$content = '<p>Links: <a href=\"\/\" target=\"_blank\">No rel<\/a><\/p>';
$expected = '<p>Links: <a href=\"\/\" target=\"_blank\" rel=\"noopener\">No rel<\/a><\/p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

/**
Expand All @@ -117,7 +117,7 @@ public function test_wp_targeted_link_rel_should_preserve_json() {
public function test_wp_targeted_link_rel_skips_style_and_scripts() {
$content = '<style><a href="/" target=a></style><p>Links: <script>console.log("<a href=\'/\' target=a>hi</a>");</script><script>alert(1);</script>here <a href="/" target=_blank>aq</a></p><script>console.log("<a href=\'last\' target=\'_blank\'")</script>';
$expected = '<style><a href="/" target=a></style><p>Links: <script>console.log("<a href=\'/\' target=a>hi</a>");</script><script>alert(1);</script>here <a href="/" target="_blank" rel="noopener">aq</a></p><script>console.log("<a href=\'last\' target=\'_blank\'")</script>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

/**
Expand All @@ -128,13 +128,13 @@ public function test_wp_targeted_link_rel_skips_style_and_scripts() {
public function test_ignore_entirely_serialized_content() {
$content = 'a:1:{s:4:"html";s:52:"<p>Links: <a href="/" target="_blank">No Rel</a></p>";}';
$expected = 'a:1:{s:4:"html";s:52:"<p>Links: <a href="/" target="_blank">No Rel</a></p>";}';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

public function test_wp_targeted_link_rel_tab_separated_values_are_split() {
$content = "<p>Links: <a href=\"/\" target=\"_blank\" rel=\"ugc\t\tnoopener\t\">No rel</a></p>";
$expected = '<p>Links: <a href="/" target="_blank" rel="ugc noopener">No rel</a></p>';
$this->assertSame( $expected, wp_targeted_link_rel( $content ) );
$this->assertEqualMarkup( $expected, wp_targeted_link_rel( $content ) );
}

}
6 changes: 3 additions & 3 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -1211,11 +1211,11 @@ public function verify_attachment_roundtrip( $input = array(), $expected_output
}

// Compare expected API output to actual API output.
$this->assertSame( $expected_output['title']['raw'], $actual_output['title']['raw'] );
$this->assertEqualMarkup( $expected_output['title']['raw'], $actual_output['title']['raw'] );
$this->assertSame( $expected_output['title']['rendered'], trim( $actual_output['title']['rendered'] ) );
$this->assertSame( $expected_output['description']['raw'], $actual_output['description']['raw'] );
$this->assertEqualMarkup( $expected_output['description']['raw'], $actual_output['description']['raw'] );
$this->assertSame( $expected_output['description']['rendered'], trim( $actual_output['description']['rendered'] ) );
$this->assertSame( $expected_output['caption']['raw'], $actual_output['caption']['raw'] );
$this->assertEqualMarkup( $expected_output['caption']['raw'], $actual_output['caption']['raw'] );
$this->assertSame( $expected_output['caption']['rendered'], trim( $actual_output['caption']['rendered'] ) );

// Compare expected API output to WP internal values.
Expand Down
Loading