diff --git a/src/js/_enqueues/wp/customize/base.js b/src/js/_enqueues/wp/customize/base.js index 4afaa945571f0..0486047b42617 100644 --- a/src/js/_enqueues/wp/customize/base.js +++ b/src/js/_enqueues/wp/customize/base.js @@ -985,6 +985,33 @@ window.wp = window.wp || {}; return queryParams; }; + /** + * Whether a URL pathname begins with one of the given excluded path prefixes. + * + * @since 7.2.0 + * @access public + * + * @alias wp.customize.utils.isExcludedPath + * + * @param {string} pathname URL pathname to test. + * @param {Array} [excludedPaths] URL path prefixes, each with a trailing slash. + * @return {boolean} Whether the pathname begins with an excluded path. + */ + api.utils.isExcludedPath = function isExcludedPath( pathname, excludedPaths ) { + var normalizedPathname; + + if ( ! excludedPaths || ! excludedPaths.length ) { + return /\/wp-(admin|includes|content)(\/|$)/.test( pathname ); + } + + // Normalize so '/wp-admin' and '//wp-admin/' both match the '/wp-admin/' prefix. + normalizedPathname = ( pathname + '/' ).replace( /\/+/g, '/' ); + + return _.some( excludedPaths, function( excludedPath ) { + return 0 === normalizedPathname.indexOf( excludedPath ); + } ); + }; + /** * Expose the API publicly on window.wp.customize * diff --git a/src/js/_enqueues/wp/customize/controls.js b/src/js/_enqueues/wp/customize/controls.js index 00123f9141c30..0ca7ceff6d1da 100644 --- a/src/js/_enqueues/wp/customize/controls.js +++ b/src/js/_enqueues/wp/customize/controls.js @@ -6534,6 +6534,9 @@ * @param {Array} params.allowedUrls * @param {string} params.container A selector or jQuery element for the preview * frame to be placed. + * @param {Array} [params.excludedPaths] URL path prefixes which are not previewable, + * such as the paths for the admin and content + * directories. Added in 7.2.0. * @param {string} params.form * @param {string} params.previewUrl The URL to preview. * @param {Object} options @@ -6571,8 +6574,9 @@ previewer.refreshBuffer ); - previewer.container = api.ensure( params.container ); - previewer.allowedUrls = params.allowedUrls; + previewer.container = api.ensure( params.container ); + previewer.allowedUrls = params.allowedUrls; + previewer.excludedPaths = params.excludedPaths; params.url = window.location.href; @@ -6596,8 +6600,8 @@ urlParser = document.createElement( 'a' ); urlParser.href = to; - // Abort if URL is for admin or (static) files in wp-includes or wp-content. - if ( /\/wp-(admin|includes|content)(\/|$)/.test( urlParser.pathname ) ) { + // Abort if URL is for the admin or (static) files in wp-includes or wp-content. + if ( api.utils.isExcludedPath( urlParser.pathname, previewer.excludedPaths ) ) { return null; } @@ -7461,10 +7465,11 @@ * @alias wp.customize.previewer */ api.previewer = new api.Previewer({ - container: '#customize-preview', - form: '#customize-controls', - previewUrl: api.settings.url.preview, - allowedUrls: api.settings.url.allowed + container: '#customize-preview', + form: '#customize-controls', + previewUrl: api.settings.url.preview, + allowedUrls: api.settings.url.allowed, + excludedPaths: api.settings.url.excludedPaths },/** @lends wp.customize.previewer */{ nonce: api.settings.nonce, diff --git a/src/js/_enqueues/wp/customize/preview.js b/src/js/_enqueues/wp/customize/preview.js index 375cd2104ba0f..9ee016329621b 100644 --- a/src/js/_enqueues/wp/customize/preview.js +++ b/src/js/_enqueues/wp/customize/preview.js @@ -270,6 +270,7 @@ * Should the supplied link is previewable. * * @since 4.7.0 + * @since 7.2.0 Non-previewable paths are read from settings instead of being hardcoded. * @access public * * @param {HTMLAnchorElement|HTMLAreaElement} element Link element. @@ -314,8 +315,8 @@ return args.allowAdminAjax; } - // Disallow links to admin, includes, and content. - if ( /\/wp-(admin|includes|content)(\/|$)/.test( element.pathname ) ) { + // Disallow links to admin, includes, content, plugins, and uploads paths, which are not previewable. + if ( api.utils.isExcludedPath( element.pathname, api.settings.url.excludedPaths ) ) { return false; } diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index e298b04efcf90..8dba6e7e098bb 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -2169,6 +2169,7 @@ public function customize_preview_settings() { 'self' => $self_url, 'allowed' => array_map( 'sanitize_url', $this->get_allowed_urls() ), 'allowedHosts' => array_unique( $allowed_hosts ), + 'excludedPaths' => $this->get_excluded_url_paths(), 'isCrossDomain' => $this->is_cross_domain(), ), 'channel' => $this->messenger_channel, @@ -4679,6 +4680,52 @@ public function get_allowed_urls() { return $allowed_urls; } + /** + * Gets URL path prefixes which are excluded from being previewed. + * + * @since 7.2.0 + * + * @return string[] URL path prefixes, each with a trailing slash. + */ + public function get_excluded_url_paths() { + $excluded_urls = array( + admin_url( '/' ), + includes_url( '/' ), + content_url( '/' ), + plugins_url( '/' ), + wp_get_upload_dir()['baseurl'], + ); + + $parsed_home = wp_parse_url( home_url( '/' ) ); + $home_host = strtolower( $parsed_home['host'] ?? '' ) . ':' . ( $parsed_home['port'] ?? '' ); + $home_path = trailingslashit( $parsed_home['path'] ?? '/' ); + + $excluded_paths = array(); + foreach ( $excluded_urls as $excluded_url ) { + $parsed = wp_parse_url( $excluded_url ); + if ( ! is_array( $parsed ) ) { + continue; + } + + // Skip URLs served from another host, such as an uploads URL pointing to a CDN. + $host = strtolower( $parsed['host'] ?? '' ) . ':' . ( $parsed['port'] ?? '' ); + if ( $host !== $home_host ) { + continue; + } + + $path = trailingslashit( $parsed['path'] ?? '' ); + + // A path at or above the home path would exclude the whole site from being previewed. + if ( str_starts_with( $home_path, $path ) ) { + continue; + } + + $excluded_paths[] = $path; + } + + return array_values( array_unique( $excluded_paths ) ); + } + /** * Gets messenger channel. * @@ -4938,6 +4985,7 @@ public function customize_pane_settings() { 'activated' => sanitize_url( home_url( '/' ) ), 'ajax' => sanitize_url( admin_url( 'admin-ajax.php', 'relative' ) ), 'allowed' => array_map( 'sanitize_url', $this->get_allowed_urls() ), + 'excludedPaths' => $this->get_excluded_url_paths(), 'isCrossDomain' => $this->is_cross_domain(), 'home' => sanitize_url( home_url( '/' ) ), 'login' => sanitize_url( $login_url ), diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index bedf0b39b3e95..d25ef59366ec9 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -2289,6 +2289,134 @@ public function filter_customize_allowed_urls( $urls ) { return $urls; } + /** + * Test WP_Customize_Manager::get_excluded_url_paths(). + * + * @ticket 65030 + * @covers WP_Customize_Manager::get_excluded_url_paths + */ + public function test_get_excluded_url_paths() { + $wp_customize = new WP_Customize_Manager(); + + $this->assertSame( + array( + '/wp-admin/', + '/wp-includes/', + '/wp-content/', + '/wp-content/plugins/', + '/wp-content/uploads/', + ), + $wp_customize->get_excluded_url_paths() + ); + } + + /** + * Test WP_Customize_Manager::get_excluded_url_paths() for a subdirectory installation. + * + * @ticket 65030 + * @covers WP_Customize_Manager::get_excluded_url_paths + */ + public function test_get_excluded_url_paths_with_subdirectory_install() { + $home_url = trailingslashit( home_url() ); + $to_subdirectory = static function ( $url ) use ( $home_url ) { + return str_replace( $home_url, $home_url . 'wp-content/subsite/', $url ); + }; + add_filter( 'admin_url', $to_subdirectory ); + add_filter( 'includes_url', $to_subdirectory ); + add_filter( 'content_url', $to_subdirectory ); + add_filter( 'plugins_url', $to_subdirectory ); + add_filter( + 'upload_dir', + static function ( $uploads ) use ( $to_subdirectory ) { + $uploads['baseurl'] = $to_subdirectory( $uploads['baseurl'] ); + return $uploads; + } + ); + + $wp_customize = new WP_Customize_Manager(); + + // The site's own base path must not be excluded, only the WP directories under it. + $this->assertSame( + array( + '/wp-content/subsite/wp-admin/', + '/wp-content/subsite/wp-includes/', + '/wp-content/subsite/wp-content/', + '/wp-content/subsite/wp-content/plugins/', + '/wp-content/subsite/wp-content/uploads/', + ), + $wp_customize->get_excluded_url_paths() + ); + } + + /** + * Test that WP_Customize_Manager::get_excluded_url_paths() never excludes the site root. + * + * @ticket 65030 + * @covers WP_Customize_Manager::get_excluded_url_paths + */ + public function test_get_excluded_url_paths_skips_site_root() { + add_filter( + 'content_url', + static function () { + return home_url( '/' ); + } + ); + + $wp_customize = new WP_Customize_Manager(); + $excluded = $wp_customize->get_excluded_url_paths(); + + $this->assertNotContains( '/', $excluded ); + $this->assertNotContains( '', $excluded ); + } + + /** + * Test that WP_Customize_Manager::get_excluded_url_paths() never excludes an ancestor of the home path. + * + * @ticket 65030 + * @covers WP_Customize_Manager::get_excluded_url_paths + */ + public function test_get_excluded_url_paths_skips_ancestors_of_home_path() { + // Simulate a site installed under wp-content whose content directory is the shared parent. + $home_url = trailingslashit( home_url() ); + add_filter( + 'home_url', + static function ( $url ) use ( $home_url ) { + return str_replace( $home_url, $home_url . 'wp-content/subsite/', $url ); + } + ); + + $wp_customize = new WP_Customize_Manager(); + $excluded = $wp_customize->get_excluded_url_paths(); + + // Excluding '/wp-content/' would exclude the whole site at '/wp-content/subsite/'. + $this->assertNotContains( '/wp-content/', $excluded ); + $this->assertContains( '/wp-admin/', $excluded ); + } + + /** + * Test that WP_Customize_Manager::get_excluded_url_paths() skips URLs served from another host. + * + * @ticket 65030 + * @covers WP_Customize_Manager::get_excluded_url_paths + */ + public function test_get_excluded_url_paths_skips_other_hosts() { + add_filter( + 'upload_dir', + static function ( $uploads ) { + $uploads['baseurl'] = 'https://cdn.example.net/media'; + return $uploads; + } + ); + + $wp_customize = new WP_Customize_Manager(); + $excluded = $wp_customize->get_excluded_url_paths(); + + // The CDN path must not block the site's own '/media/' pages, and the CDN itself is not previewable. + $this->assertNotContains( '/media/', $excluded ); + $this->assertNotContains( '/wp-content/uploads/', $excluded ); + $this->assertContains( '/wp-content/', $excluded ); + } + /** * Test WP_Customize_Manager::doing_ajax(). * @@ -3147,6 +3275,7 @@ public function test_customize_pane_settings() { $this->assertSame( $autofocus, $data['autofocus'] ); $this->assertArrayHasKey( 'save', $data['nonce'] ); $this->assertArrayHasKey( 'preview', $data['nonce'] ); + $this->assertSame( $this->manager->get_excluded_url_paths(), $data['url']['excludedPaths'] ); $this->assertSameSets( array( @@ -3218,6 +3347,7 @@ public function test_customize_preview_settings() { $this->assertArrayHasKey( 'changeset', $settings ); $this->assertArrayHasKey( 'preview', $settings['nonce'] ); + $this->assertSame( $this->manager->get_excluded_url_paths(), $settings['url']['excludedPaths'] ); } /** diff --git a/tests/qunit/wp-admin/js/customize-base.js b/tests/qunit/wp-admin/js/customize-base.js index bd3535b3bc3b0..16dd6294c72a0 100644 --- a/tests/qunit/wp-admin/js/customize-base.js +++ b/tests/qunit/wp-admin/js/customize-base.js @@ -249,4 +249,33 @@ jQuery( function( $ ) { queryParams = wp.customize.utils.parseQueryString( 'a=1&b=' ); assert.ok( _.isEqual( queryParams, { 'a': '1', b: '' } ) ); } ); + + QUnit.module( 'Customize Base: utils.isExcludedPath' ); + QUnit.test( 'wp.customize.utils.isExcludedPath works', function( assert ) { + var excludedPaths = [ '/wp-admin/', '/wp-includes/', '/wp-content/' ]; + + // Excluded paths and everything beneath them match. + assert.ok( wp.customize.utils.isExcludedPath( '/wp-admin/', excludedPaths ) ); + assert.ok( wp.customize.utils.isExcludedPath( '/wp-admin', excludedPaths ), 'bare directory without trailing slash matches' ); + assert.ok( wp.customize.utils.isExcludedPath( '/wp-content/uploads/file.pdf', excludedPaths ) ); + assert.ok( wp.customize.utils.isExcludedPath( '//wp-admin/', excludedPaths ), 'repeated slashes are collapsed' ); + + // Front-end paths do not match. + assert.ok( ! wp.customize.utils.isExcludedPath( '/', excludedPaths ) ); + assert.ok( ! wp.customize.utils.isExcludedPath( '/sample-page/', excludedPaths ) ); + assert.ok( ! wp.customize.utils.isExcludedPath( '/wp-admin2/', excludedPaths ), 'similarly named directory does not match' ); + assert.ok( ! wp.customize.utils.isExcludedPath( '/blog/wp-content/', excludedPaths ), 'prefixes are anchored to the start of the path' ); + + // Subdirectory install: the site's own base path is not excluded (see #65030). + excludedPaths = [ '/wp-content/subsite/wp-admin/', '/wp-content/subsite/wp-content/' ]; + assert.ok( ! wp.customize.utils.isExcludedPath( '/wp-content/subsite/', excludedPaths ) ); + assert.ok( ! wp.customize.utils.isExcludedPath( '/wp-content/subsite/sample-page/', excludedPaths ) ); + assert.ok( wp.customize.utils.isExcludedPath( '/wp-content/subsite/wp-admin/', excludedPaths ) ); + + // Without excluded paths, the legacy hardcoded paths are matched. + assert.ok( wp.customize.utils.isExcludedPath( '/wp-admin/', undefined ) ); + assert.ok( wp.customize.utils.isExcludedPath( '/blog/wp-content/', undefined ), 'legacy fallback matches anywhere in the path' ); + assert.ok( ! wp.customize.utils.isExcludedPath( '/sample-page/', undefined ) ); + assert.ok( wp.customize.utils.isExcludedPath( '/wp-admin/', [] ), 'empty list falls back to legacy paths' ); + } ); });