Skip to content

Commit 1f7f3f1

Browse files
committed
Prevent stored XSS through wp_targeted_link_rel().
Brings r46894 to the 5.3 branch. Props: vortfu, whyisjake, peterwilsoncc, xknown, SergeyBiryukov, flaviozavan. git-svn-id: https://develop.svn.wordpress.org/branches/5.3@46898 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 1d1d5be commit 1f7f3f1

File tree

2 files changed

+56
-49
lines changed

2 files changed

+56
-49
lines changed

Diff for: src/wp-includes/formatting.php

+45-34
Original file line numberDiff line numberDiff line change
@@ -3146,9 +3146,25 @@ function( $matches ) {
31463146
*/
31473147
function wp_targeted_link_rel( $text ) {
31483148
// Don't run (more expensive) regex if no links with targets.
3149-
if ( stripos( $text, 'target' ) !== false && stripos( $text, '<a ' ) !== false ) {
3150-
if ( ! is_serialized( $text ) ) {
3151-
$text = preg_replace_callback( '|<a\s([^>]*target\s*=[^>]*)>|i', 'wp_targeted_link_rel_callback', $text );
3149+
if ( stripos( $text, 'target' ) === false || stripos( $text, '<a ' ) === false || is_serialized( $text ) ) {
3150+
return $text;
3151+
}
3152+
3153+
$script_and_style_regex = '/<(script|style).*?<\/\\1>/si';
3154+
3155+
preg_match_all( $script_and_style_regex, $text, $matches );
3156+
$extra_parts = $matches[0];
3157+
$html_parts = preg_split( $script_and_style_regex, $text );
3158+
3159+
foreach ( $html_parts as &$part ) {
3160+
$part = preg_replace_callback( '|<a\s([^>]*target\s*=[^>]*)>|i', 'wp_targeted_link_rel_callback', $part );
3161+
}
3162+
3163+
$text = '';
3164+
for ( $i = 0; $i < count( $html_parts ); $i++ ) {
3165+
$text .= $html_parts[ $i ];
3166+
if ( isset( $extra_parts[ $i ] ) ) {
3167+
$text .= $extra_parts[ $i ];
31523168
}
31533169
}
31543170

@@ -3167,8 +3183,17 @@ function wp_targeted_link_rel( $text ) {
31673183
* @return string HTML A Element with rel noreferrer noopener in addition to any existing values
31683184
*/
31693185
function wp_targeted_link_rel_callback( $matches ) {
3170-
$link_html = $matches[1];
3171-
$rel_match = array();
3186+
$link_html = $matches[1];
3187+
$original_link_html = $link_html;
3188+
3189+
// Consider the html escaped if there are no unescaped quotes
3190+
$is_escaped = ! preg_match( '/(^|[^\\\\])[\'"]/', $link_html );
3191+
if ( $is_escaped ) {
3192+
// Replace only the quotes so that they are parsable by wp_kses_hair, leave the rest as is
3193+
$link_html = preg_replace( '/\\\\([\'"])/', '$1', $link_html );
3194+
}
3195+
3196+
$atts = wp_kses_hair( $link_html, wp_allowed_protocols() );
31723197

31733198
/**
31743199
* Filters the rel values that are added to links with `target` attribute.
@@ -3180,35 +3205,21 @@ function wp_targeted_link_rel_callback( $matches ) {
31803205
*/
31813206
$rel = apply_filters( 'wp_targeted_link_rel', 'noopener noreferrer', $link_html );
31823207

3183-
// Avoid additional regex if the filter removes rel values.
3184-
if ( ! $rel ) {
3185-
return "<a $link_html>";
3186-
}
3187-
3188-
// Value with delimiters, spaces around are optional.
3189-
$attr_regex = '|rel\s*=\s*?(\\\\{0,1}["\'])(.*?)\\1|i';
3190-
preg_match( $attr_regex, $link_html, $rel_match );
3191-
3192-
if ( empty( $rel_match[0] ) ) {
3193-
// No delimiters, try with a single value and spaces, because `rel = va"lue` is totally fine...
3194-
$attr_regex = '|rel\s*=(\s*)([^\s]*)|i';
3195-
preg_match( $attr_regex, $link_html, $rel_match );
3196-
}
3197-
3198-
if ( ! empty( $rel_match[0] ) ) {
3199-
$parts = preg_split( '|\s+|', strtolower( $rel_match[2] ) );
3200-
$parts = array_map( 'esc_attr', $parts );
3201-
$needed = explode( ' ', $rel );
3202-
$parts = array_unique( array_merge( $parts, $needed ) );
3203-
$delimiter = trim( $rel_match[1] ) ? $rel_match[1] : '"';
3204-
$rel = 'rel=' . $delimiter . trim( implode( ' ', $parts ) ) . $delimiter;
3205-
$link_html = str_replace( $rel_match[0], $rel, $link_html );
3206-
} elseif ( preg_match( '|target\s*=\s*?\\\\"|', $link_html ) ) {
3207-
$link_html .= " rel=\\\"$rel\\\"";
3208-
} elseif ( preg_match( '#(target|href)\s*=\s*?\'#', $link_html ) ) {
3209-
$link_html .= " rel='$rel'";
3210-
} else {
3211-
$link_html .= " rel=\"$rel\"";
3208+
// Return early if no rel values to be added or if no actual target attribute
3209+
if ( ! $rel || ! isset( $atts['target'] ) ) {
3210+
return "<a $original_link_html>";
3211+
}
3212+
3213+
if ( isset( $atts['rel'] ) ) {
3214+
$all_parts = preg_split( '/\s/', "{$atts['rel']['value']} $rel", -1, PREG_SPLIT_NO_EMPTY );
3215+
$rel = implode( ' ', array_unique( $all_parts ) );
3216+
}
3217+
3218+
$atts['rel']['whole'] = 'rel="' . esc_attr( $rel ) . '"';
3219+
$link_html = join( ' ', array_column( $atts, 'whole' ) );
3220+
3221+
if ( $is_escaped ) {
3222+
$link_html = preg_replace( '/[\'"]/', '\\\\$0', $link_html );
32123223
}
32133224

32143225
return "<a $link_html>";

Diff for: tests/phpunit/tests/formatting/WPTargetedLinkRel.php

+11-15
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public function test_no_duplicate_values_added() {
3838

3939
public function test_rel_with_single_quote_delimiter() {
4040
$content = '<p>Links: <a href="/" rel=\'existing values\' target="_blank">Existing rel</a></p>';
41-
$expected = '<p>Links: <a href="/" rel=\'existing values noopener noreferrer\' target="_blank">Existing rel</a></p>';
41+
$expected = '<p>Links: <a href="/" rel="existing values noopener noreferrer" target="_blank">Existing rel</a></p>';
4242
$this->assertEquals( $expected, wp_targeted_link_rel( $content ) );
4343
}
4444

@@ -54,12 +54,6 @@ public function test_rel_value_spaced_and_no_delimiter() {
5454
$this->assertEquals( $expected, wp_targeted_link_rel( $content ) );
5555
}
5656

57-
public function test_rel_value_spaced_and_no_delimiter_and_values_to_escape() {
58-
$content = '<p>Links: <a href="/" rel = existing"value target="_blank">Existing rel</a></p>';
59-
$expected = '<p>Links: <a href="/" rel="existing&quot;value noopener noreferrer" target="_blank">Existing rel</a></p>';
60-
$this->assertEquals( $expected, wp_targeted_link_rel( $content ) );
61-
}
62-
6357
public function test_escaped_quotes() {
6458
$content = '<p>Links: <a href=\"/\" rel=\"existing values\" target=\"_blank\">Existing rel</a></p>';
6559
$expected = '<p>Links: <a href=\"/\" rel=\"existing values noopener noreferrer\" target=\"_blank\">Existing rel</a></p>';
@@ -114,17 +108,13 @@ public function test_wp_targeted_link_rel_should_preserve_json() {
114108
}
115109

116110
/**
117-
* Ensure correct quotes are used when relation attribute (rel) is missing.
111+
* Ensure the content of style and script tags are not processed
118112
*
119113
* @ticket 47244
120114
*/
121-
public function test_wp_targeted_link_rel_should_use_correct_quotes() {
122-
$content = '<p>Links: <a href=\'\/\' target=\'_blank\'>No rel<\/a><\/p>';
123-
$expected = '<p>Links: <a href=\'\/\' target=\'_blank\' rel=\'noopener noreferrer\'>No rel<\/a><\/p>';
124-
$this->assertEquals( $expected, wp_targeted_link_rel( $content ) );
125-
126-
$content = '<p>Links: <a href=\'\/\' target=_blank>No rel<\/a><\/p>';
127-
$expected = '<p>Links: <a href=\'\/\' target=_blank rel=\'noopener noreferrer\'>No rel<\/a><\/p>';
115+
public function test_wp_targeted_link_rel_skips_style_and_scripts() {
116+
$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>';
117+
$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 noreferrer">aq</a></p><script>console.log("<a href=\'last\' target=\'_blank\'")</script>';
128118
$this->assertEquals( $expected, wp_targeted_link_rel( $content ) );
129119
}
130120

@@ -139,4 +129,10 @@ public function test_ignore_entirely_serialized_content() {
139129
$this->assertEquals( $expected, wp_targeted_link_rel( $content ) );
140130
}
141131

132+
public function test_wp_targeted_link_rel_tab_separated_values_are_split() {
133+
$content = "<p>Links: <a href=\"/\" target=\"_blank\" rel=\"ugc\t\tnoopener\t\">No rel</a></p>";
134+
$expected = '<p>Links: <a href="/" target="_blank" rel="ugc noopener noreferrer">No rel</a></p>';
135+
$this->assertEquals( $expected, wp_targeted_link_rel( $content ) );
136+
}
137+
142138
}

0 commit comments

Comments
 (0)