Make WordPress Core

Opened 6 weeks ago

Last modified 6 weeks ago

#65030 accepted defect (bug)

Customize: `isLinkPreviewable()` regex overrides `customize_allowed_urls` filter for sites with `/wp-content/` in home URL

Reported by: abhi3315's profile abhi3315 Owned by: westonruter's profile westonruter
Milestone: 7.1 Priority: normal
Severity: normal Version:
Component: Customize Keywords: has-patch changes-requested
Focuses: javascript Cc:

Description

Summary

The customize_allowed_urls PHP filter allows plugins and themes to register additional previewable URLs for the Customizer. However, the hardcoded regex in isLinkPreviewable() in customize-preview.js overrides this filter for any URL whose pathname contains /wp-content/ — making the filter incomplete.

When a URL passes the settings.url.allowed check (populated by customize_allowed_urls), it should be considered previewable. Instead, the regex silently rejects it, and there is no JS-side filter or hook to override this behavior.

Steps to reproduce

  1. Install WordPress in a subdirectory under wp-content/ (e.g., example.com/wp-content/subsite/)
  2. Confirm home_url('/') returns the wp-content based URL
  3. Log in and open Appearance → Customize
  4. The preview iframe fails to load

Note: get_allowed_urls() includes home_url('/') by default, so the site's own URL is in the allowed list — yet the Customizer still rejects it.

Root cause

In src/js/_enqueues/wp/customize/preview.js, the isLinkPreviewable() function runs two checks in sequence:

// Check 1: URL matches an explicitly allowed URL — PASSES
matchesAllowedUrl = ! _.isUndefined( _.find( api.settings.url.allowed, function( allowedUrl ) {
    parsedAllowedUrl.href = allowedUrl;
    return parsedAllowedUrl.protocol === element.protocol
        && parsedAllowedUrl.host === element.host
        && 0 === element.pathname.indexOf( parsedAllowedUrl.pathname );
} ) );
if ( ! matchesAllowedUrl ) {
    return false;
}

// Check 2: Hardcoded regex — overrides check 1 for URLs containing /wp-content/
if ( /\/wp-(admin|includes|content)(\/|$)/.test( element.pathname ) ) {
    return false;
}

Check 2 does not consider whether the URL was already approved by check 1. A URL can be explicitly allowed via the PHP filter and still be blocked by the JS regex.

The same issue exists in the previewUrl setter in customize-controls.js.

Expected behavior

URLs that match an entry in settings.url.allowed (populated via the customize_allowed_urls PHP filter) should not be overridden by the hardcoded regex. The regex should only apply to URLs that did not match the allowed list.

Proposed fix

// Only apply the regex block for URLs that were NOT explicitly allowed
if ( ! matchesAllowedUrl && /\/wp-(admin|includes|content)(\/|$)/.test( element.pathname ) ) {
    return false;
}

This is backward-compatible. If no allowed URL contains /wp-content/, behavior is identical to today. It only changes behavior when the PHP filter has explicitly approved a URL that happens to contain /wp-content/ in its path.

Related tickets

  • #38409 — Hardened URL matching in isLinkPreviewable
  • #31517 — Added non-previewable link notice in Customizer
  • #30937 — Customize changesets, introduced isLinkPreviewable

Change History (3)

This ticket was mentioned in PR #11459 on WordPress/wordpress-develop by @abhi3315.


6 weeks ago
#1

  • Keywords has-patch added

The hardcoded regex in isLinkPreviewable() and the previewUrl setter blocks any URL with /wp-content/ in the path, even when the URL was explicitly allowed via customize_allowed_urls. This breaks the Customizer for sites installed under a /wp-content/ subdirectory.

Fix: capture the matched allowed URL's path prefix, apply the regex only to the remainder beyond it.

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.6
Used for: Analysis of the bug, implementation approach, and code changes. Final implementation reviewed and verified by me.

#2 @abhi3315
6 weeks ago

Updated the proposed fix. The original ! matchesAllowedUrl approach wouldn't work because matchesAllowedUrl is always true at that point. The correct fix captures the matched allowed URL's path prefix and applies the regex only to the remainder. See PR: https://github.com/WordPress/wordpress-develop/pull/11459

#3 @westonruter
6 weeks ago

  • Keywords changes-requested added
  • Milestone changed from Awaiting Review to 7.1
  • Owner set to westonruter
  • Status changed from new to accepted
  • Version 6.9.4 deleted

I can see this would be a problem, although it seems very uncommon.

Really, the approach was wrong to begin with in how the wp-admin, wp-includes, and wp-content paths are hard-coded. Really, these should be exported from PHP to JS, using an approach similar to what can be seen in the Speculative Loading functionality. See \WP_URL_Pattern_Prefixer::get_default_contexts(). So we should be obtaining the paths from admin_url(), plugins_url(), and content_url(), for example. This would be a more robust way to prevent navigations to URLs that aren't served by WP.

Note: See TracTickets for help on using tickets.