diff --git a/src/wp-includes/feed-atom-comments.php b/src/wp-includes/feed-atom-comments.php index 1de77e391794d..c0dc67f811f69 100644 --- a/src/wp-includes/feed-atom-comments.php +++ b/src/wp-includes/feed-atom-comments.php @@ -14,14 +14,18 @@ @@ -26,6 +24,9 @@ /** * Fires at the end of the RSS root to add namespaces. * + * Consider using the `wp_feed_namespaces` filter instead, which + * prevents duplicate `xmlns` attributes. + * * @since 2.8.0 */ do_action( 'rss2_comments_ns' ); diff --git a/src/wp-includes/feed-rss2.php b/src/wp-includes/feed-rss2.php index 8b30313e6d606..a34dc2d460b13 100644 --- a/src/wp-includes/feed-rss2.php +++ b/src/wp-includes/feed-rss2.php @@ -21,16 +21,15 @@ do_action( 'rss_tag_pre', 'rss2' ); ?> Array of namespace URIs, keyed by their prefix. + * @phpstan-param 'rss2'|'rss2-comments'|'rdf'|'atom'|'atom-comments' $type + * @phpstan-return array + */ +function get_feed_namespaces( string $type ): array { + $defaults = array(); + + switch ( $type ) { + case 'rss2': + $defaults = array( + 'content' => 'http://purl.org/rss/1.0/modules/content/', + 'wfw' => 'http://wellformedweb.org/CommentAPI/', + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'atom' => 'http://www.w3.org/2005/Atom', + 'sy' => 'http://purl.org/rss/1.0/modules/syndication/', + 'slash' => 'http://purl.org/rss/1.0/modules/slash/', + ); + break; + + case 'rss2-comments': + $defaults = array( + 'content' => 'http://purl.org/rss/1.0/modules/content/', + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'atom' => 'http://www.w3.org/2005/Atom', + 'sy' => 'http://purl.org/rss/1.0/modules/syndication/', + ); + break; + + case 'rdf': + $defaults = array( + 'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'sy' => 'http://purl.org/rss/1.0/modules/syndication/', + 'admin' => 'http://webns.net/mvcb/', + 'content' => 'http://purl.org/rss/1.0/modules/content/', + ); + break; + + case 'atom': + case 'atom-comments': + $defaults = array( + 'thr' => 'http://purl.org/syndication/thread/1.0', + ); + break; + } + + /** + * Filters the XML namespaces of a feed's root element. + * + * Namespaces are keyed by their prefix, which makes duplicate `xmlns` + * attributes impossible. + * + * @since 7.2.0 + * + * @param array $namespaces Array of namespace URIs, keyed by their prefix. + * @param string $type Type of feed. Possible values include 'rss2', + * 'rss2-comments', 'rdf', 'atom', and 'atom-comments'. + */ + $namespaces = apply_filters( 'wp_feed_namespaces', $defaults, $type ); + + if ( ! is_array( $namespaces ) ) { + $namespaces = array(); + } + + // The bundled feed templates use the default namespaces, so they cannot be removed. + $namespaces = array_merge( $namespaces, $defaults ); + + $sanitized = array(); + + foreach ( $namespaces as $prefix => $uri ) { + $prefix = (string) $prefix; + + // Prefixes must be valid XML names, and the `xml` and `xmlns` prefixes are reserved. + if ( ! preg_match( '/^[\p{L}_][\p{L}\p{M}\p{N}._\-\x{B7}]*$/u', $prefix ) + || in_array( strtolower( $prefix ), array( 'xml', 'xmlns' ), true ) + ) { + continue; + } + + if ( ! is_string( $uri ) || empty( $uri ) ) { + continue; + } + + $sanitized[ $prefix ] = $uri; + } + + return $sanitized; +} + +/** + * Displays the XML namespaces for the root element of a feed. + * + * Plugins should add namespaces via the `wp_feed_namespaces` filter instead + * of the older `{$type}_ns` actions, as two action callbacks printing the + * same namespace produce a duplicate attribute, which is a well-formedness + * error in XML. + * + * @since 7.2.0 + * + * @param string $type Type of feed. Possible values include 'rss2', 'rss2-comments', + * 'rdf', 'atom', and 'atom-comments'. + * @phpstan-param 'rss2'|'rss2-comments'|'rdf'|'atom'|'atom-comments' $type + */ +function feed_namespaces( string $type ): void { + foreach ( get_feed_namespaces( $type ) as $prefix => $uri ) { + printf( "xmlns:%s=\"%s\"\n\t", $prefix, esc_url( $uri ) ); + } +} + /** * Gets the UTC time of the most recently modified post from WP_Query. * diff --git a/tests/phpunit/tests/feed/feedNamespaces.php b/tests/phpunit/tests/feed/feedNamespaces.php new file mode 100644 index 0000000000000..a8d1020271180 --- /dev/null +++ b/tests/phpunit/tests/feed/feedNamespaces.php @@ -0,0 +1,228 @@ +assertSameSetsWithIndex( + array( + 'content' => 'http://purl.org/rss/1.0/modules/content/', + 'wfw' => 'http://wellformedweb.org/CommentAPI/', + 'dc' => 'http://purl.org/dc/elements/1.1/', + 'atom' => 'http://www.w3.org/2005/Atom', + 'sy' => 'http://purl.org/rss/1.0/modules/syndication/', + 'slash' => 'http://purl.org/rss/1.0/modules/slash/', + ), + $namespaces + ); + } + + /** + * @ticket 65785 + */ + public function test_should_return_default_atom_namespaces() { + $namespaces = get_feed_namespaces( 'atom' ); + + $this->assertSameSetsWithIndex( + array( + 'thr' => 'http://purl.org/syndication/thread/1.0', + ), + $namespaces + ); + } + + /** + * @ticket 65785 + */ + public function test_should_return_namespace_added_via_filter() { + add_filter( + 'wp_feed_namespaces', + static function ( array $namespaces ): array { + $namespaces['source'] = 'http://source.scripting.com/'; + return $namespaces; + } + ); + + $namespaces = get_feed_namespaces( 'rss2' ); + + $this->assertArrayHasKey( 'source', $namespaces ); + $this->assertSame( 'http://source.scripting.com/', $namespaces['source'] ); + } + + /** + * The feed type is passed to the filter, so namespaces can be added to + * specific feeds only. + * + * @ticket 65785 + */ + public function test_filter_should_receive_the_feed_type() { + add_filter( + 'wp_feed_namespaces', + static function ( array $namespaces, string $type ): array { + if ( 'rss2' === $type ) { + $namespaces['source'] = 'http://source.scripting.com/'; + } + return $namespaces; + }, + 10, + 2 + ); + + $this->assertArrayHasKey( 'source', get_feed_namespaces( 'rss2' ) ); + $this->assertArrayNotHasKey( 'source', get_feed_namespaces( 'atom' ) ); + } + + /** + * The bundled feed templates use the default namespaces in their static + * markup, so a filter must not be able to remove them. + * + * @ticket 65785 + */ + public function test_should_not_allow_removing_default_namespaces() { + add_filter( + 'wp_feed_namespaces', + static function () { + return array( 'media' => 'http://search.yahoo.com/mrss/' ); + } + ); + + $namespaces = get_feed_namespaces( 'rss2' ); + + $this->assertArrayHasKey( 'media', $namespaces ); + $this->assertArrayHasKey( 'content', $namespaces ); + $this->assertArrayHasKey( 'atom', $namespaces ); + } + + /** + * A filter callback without a return value must not break the feed. + * + * @ticket 65785 + */ + public function test_should_handle_a_non_array_filter_return() { + add_filter( 'wp_feed_namespaces', '__return_null' ); + + $namespaces = get_feed_namespaces( 'rss2' ); + + $this->assertArrayHasKey( 'content', $namespaces ); + } + + /** + * @ticket 65785 + */ + public function test_should_skip_invalid_prefixes() { + add_filter( + 'wp_feed_namespaces', + static function ( array $namespaces ): array { + $namespaces[''] = 'http://example.org/empty'; + $namespaces['foo bar'] = 'http://example.org/space'; + $namespaces['"onload="x"'] = 'http://example.org/attack'; + $namespaces['0numeric'] = 'http://example.org/numeric'; + $namespaces['xmlns'] = 'http://example.org/reserved'; + $namespaces['XML'] = 'http://example.org/reserved-too'; + $namespaces['empty-uri'] = ''; + return $namespaces; + } + ); + + $namespaces = get_feed_namespaces( 'rss2' ); + + $this->assertArrayNotHasKey( '', $namespaces ); + $this->assertArrayNotHasKey( 'foo bar', $namespaces ); + $this->assertArrayNotHasKey( '"onload="x"', $namespaces ); + $this->assertArrayNotHasKey( '0numeric', $namespaces ); + $this->assertArrayNotHasKey( 'xmlns', $namespaces ); + $this->assertArrayNotHasKey( 'XML', $namespaces ); + $this->assertArrayNotHasKey( 'empty-uri', $namespaces ); + } + + /** + * Prefixes are XML names: mixed case like `creativeCommons` and + * non-ASCII letters are valid and must be preserved. + * + * @ticket 65785 + */ + public function test_should_preserve_valid_prefixes() { + add_filter( + 'wp_feed_namespaces', + static function ( array $namespaces ): array { + $namespaces['creativeCommons'] = 'http://backend.userland.com/creativeCommonsRssModule'; + $namespaces['média'] = 'http://example.org/media'; + $namespaces['tag-uri'] = 'tag:example.org,2004:ns'; + return $namespaces; + } + ); + + $namespaces = get_feed_namespaces( 'rss2' ); + + $this->assertArrayHasKey( 'creativeCommons', $namespaces ); + $this->assertArrayHasKey( 'média', $namespaces ); + // Namespace URIs are identifiers, not links, so non-http schemes must survive. + $this->assertSame( 'tag:example.org,2004:ns', $namespaces['tag-uri'] ); + } + + /** + * @ticket 65785 + */ + public function test_should_return_an_empty_array_for_an_unknown_type() { + $this->assertSame( array(), get_feed_namespaces( 'unknown' ) ); // @phpstan-ignore argument.type (Intentionally passing unsupported type.) + } + + /** + * @ticket 65785 + */ + public function test_feed_namespaces_should_print_default_namespaces() { + $output = get_echo( 'feed_namespaces', array( 'rss2' ) ); + + $this->assertStringContainsString( 'xmlns:content="http://purl.org/rss/1.0/modules/content/"', $output ); + $this->assertStringContainsString( 'xmlns:slash="http://purl.org/rss/1.0/modules/slash/"', $output ); + } + + /** + * @ticket 65785 + */ + public function test_feed_namespaces_should_escape_the_namespace_uri() { + add_filter( + 'wp_feed_namespaces', + static function ( array $namespaces ): array { + $namespaces['evil'] = 'http://example.org/">