diff --git a/lib/tests/moodle_url_test.php b/lib/tests/moodle_url_test.php index 61b39455e64..b575dfd4d35 100644 --- a/lib/tests/moodle_url_test.php +++ b/lib/tests/moodle_url_test.php @@ -24,7 +24,7 @@ namespace core; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \moodle_url */ -class moodle_url_test extends \advanced_testcase { +final class moodle_url_test extends \advanced_testcase { /** * Test basic moodle_url construction. */ @@ -399,4 +399,71 @@ class moodle_url_test extends \advanced_testcase { ], ]; } + + /** + * @dataProvider url_fragment_parsing_provider + */ + public function test_url_fragment_parsing(string $fragment, string $expected): void { + $url = new \moodle_url('/index.php', null, $fragment); + + // Test the encoded fragment. + $this->assertEquals( + "#{$expected}", + $url->get_encoded_anchor(), + ); + + // Test the value of ->raw_out() with escaping enabled. + $parts = parse_url($url->raw_out(true), PHP_URL_FRAGMENT); + $this->assertEquals($expected, parse_url($url->raw_out(true), PHP_URL_FRAGMENT)); + + // Test the value of ->raw_out() with escaping disabled. + $parts = parse_url($url->raw_out(false)); + $this->assertEquals($expected, $parts['fragment']); + + // Test the value of ->out() with escaping enabled. + $parts = parse_url($url->out(true)); + $this->assertEquals($expected, $parts['fragment']); + + // Test the value of ->out() with escaping disabled. + $parts = parse_url($url->out(false)); + $this->assertEquals($expected, $parts['fragment']); + + $url->set_anchor($fragment); + $this->assertEquals( + "#{$expected}", + $url->get_encoded_anchor(), + ); + } + + /** + * Data provider for url_fragment_parsing tests. + * + * @return array + */ + public static function url_fragment_parsing_provider(): array { + return [ + 'Simple fragment' => ['test', 'test'], + // RFC 3986 allows the following characters in a fragment without them being encoded: + // pct-encoded: "%" HEXDIG HEXDIG + // unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~" / + // sub-delims: "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" / ":" / "@" + // fragment: "/" / "?" + // + // These should not be encoded in the fragment unless they were already encoded. + 'Fragment with RFC3986 characters' => [ + 'test-._~!$&\'()*+,;=:@/?', + 'test-._~!$&\'()*+,;=:@/?', + ], + 'Fragment with already-encoded RFC3986 characters' => [ + rawurlencode('test-._~!$&\'()*+,;=:@/?'), + rawurlencode('test-._~!$&\'()*+,;=:@/?'), + ], + 'Fragment with encoded slashes' => ['test%2fwith%2fencoded%2fslashes', 'test%2fwith%2fencoded%2fslashes'], + 'Fragment with encoded characters' => ['test%20with%20encoded%20characters', 'test%20with%20encoded%20characters'], + + // The following are examples which _should_ become encoded. + 'Spaces become encoded' => ['test with spaces', 'test%20with%20spaces'], + 'Quotes become encoded' => ['test with "quotes"', 'test%20with%20%22quotes%22'], + ]; + } } diff --git a/lib/weblib.php b/lib/weblib.php index 8415fe0a90b..e53d5916eae 100644 --- a/lib/weblib.php +++ b/lib/weblib.php @@ -618,13 +618,44 @@ class moodle_url { if ($querystring !== '') { $uri .= '?' . $querystring; } - if (!is_null($this->anchor)) { - $uri .= '#'.$this->anchor; - } + + $uri .= $this->get_encoded_anchor(); return $uri; } + /** + * Encode the anchor according to RFC 3986. + * + * @return string The encoded anchor + */ + public function get_encoded_anchor(): string { + if (is_null($this->anchor)) { + return ''; + } + + // RFC 3986 allows the following characters in a fragment without them being encoded: + // pct-encoded: "%" HEXDIG HEXDIG + // unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~" / + // sub-delims: "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" / ":" / "@" + // fragment: "/" / "?" + // + // All other characters should be encoded. + // These should not be encoded in the fragment unless they were already encoded. + + $allowed = 'a-zA-Z0-9\\-._~!$&\'()*+,;=:@\/?%'; + $anchor = '#'; + $anchor .= preg_replace_callback( + '/[^' . $allowed . ']/', + function ($matches) { + return rawurlencode($matches[0]); + }, + $this->anchor + ); + + return $anchor; + } + /** * Returns url without parameters, everything before '?'. * @@ -638,8 +669,8 @@ class moodle_url { $uri .= $this->host ? $this->host : ''; $uri .= $this->port ? ':'.$this->port : ''; $uri .= $this->path ? $this->path : ''; - if ($includeanchor and !is_null($this->anchor)) { - $uri .= '#' . $this->anchor; + if ($includeanchor) { + $uri .= $this->get_encoded_anchor(); } return $uri; @@ -715,15 +746,8 @@ class moodle_url { if (is_null($anchor)) { // Remove. $this->anchor = null; - } else if ($anchor === '') { - // Special case, used as empty link. - $this->anchor = ''; - } else if (preg_match('|[a-zA-Z\_\:][a-zA-Z0-9\_\-\.\:]*|', $anchor)) { - // Match the anchor against the NMTOKEN spec. - $this->anchor = $anchor; } else { - // Bad luck, no valid anchor found. - $this->anchor = null; + $this->anchor = $anchor; } }