Skip to content

Snowboard's Url utility mangles protocol-relative asset URLs, hanging AJAX handlers #1538

Description

@LukeTowers

Winter CMS Build

dev-develop

PHP Version

8.4

Database engine

MySQL/MariaDB

Plugins installed

Winter.Location, LukeTowers.EasyAudit

Issue description

Snowboard's Url utility does not recognise protocol-relative URLs (//example.com/script.js). Both to() and asset() test the URL against a regex that requires an explicit scheme, and when it doesn't match they strip all leading slashes and resolve the remainder against the site:

https://github.com/wintercms/winter/blob/develop/modules/system/assets/js/snowboard/utilities/Url.js#L27-L56

const urlRegex = /^(?:[^:]+:\/\/)[-a-z0-9@:%._+~#=]{1,256}\b([-a-z0-9()@:%_+.~#?&//=]*)/i;

if (url.match(urlRegex)) {
    return url;
}

const theUrl = url.replace(/^\/+/, '');   // <- strips both slashes of "//host/path"

return `${this.assetUrl()}${theUrl}`;

So //maps.googleapis.com/maps/api/js becomes https://mysite.test/maps.googleapis.com/maps/api/js.

This is mostly invisible until an asset is injected over AJAX, because AssetMaker::getAssetPath() and getAssetScheme() on the PHP side both pass protocol-relative URLs through untouched, so a full page render emits a working <script src="//host/...">. It's only when the same asset arrives via X_WINTER_ASSETS and goes through AssetLoader.loadScript()Url.asset() that it gets rewritten.

The failure is worse than a 404 on an unused asset: AssetLoader.loadScript() rejects its promise when the script errors, and handleUpdateResponse awaits it, so the AJAX handler never completes. In my case that left a popup spinning forever with no visible error other than the 404 in the console.

This affects Winter.Location's AddressFinder form widget out of the box, which registers the Maps API this way:

https://github.com/wintercms/wn-location-plugin/blob/main/formwidgets/AddressFinder.php#L92

$this->addJs('//maps.googleapis.com/maps/api/js?libraries=places&key='.$apiKey);

So any backend form containing an AddressFinder breaks on its first AJAX request. I hit it via LukeTowers.EasyAudit, whose audit log tab opens a popup over AJAX, but any handler on such a form would do it. I'm submitting a PR against that plugin to switch the widget to https://, but the underlying Url behaviour seems worth fixing regardless — a protocol-relative URL is perfectly valid and is being silently turned into a same-origin path.

Observed console output:

GET https://mysite.test/maps.googleapis.com/maps/api/js?libraries=places&key=… net::ERR_ABORTED 404 (Not Found)
    at loadScript (system.js)
    at load (system.js)
    at handleUpdateResponse (framework.js:398)

Uncaught (in promise) Error: Unable to load script file:
"https://mysite.test/maps.googleapis.com/maps/api/js?libraries=places&key=…"

Steps to replicate

  1. In a backend controller, register a protocol-relative asset, e.g. $this->addJs('//code.jquery.com/jquery-3.7.1.min.js');
  2. Trigger any AJAX handler on that page.
  3. Observe the request for https://<site>/code.jquery.com/jquery-3.7.1.min.js returning 404, and the handler's response never being applied.

The same result can be reproduced against the regex alone:

const urlRegex = /^(?:[^:]+:\/\/)[-a-z0-9@:%._+~#=]{1,256}\b([-a-z0-9()@:%_+.~#?&//=]*)/i;
'//code.jquery.com/jquery-3.7.1.min.js'.match(urlRegex);   // null

Workaround

Registering the asset with an explicit scheme (https://…) avoids it, since the URL then matches the regex and is returned unchanged.

Suggested fix

Return protocol-relative URLs unchanged in both to() and asset(), alongside the existing check:

if (url.startsWith('//') || url.match(urlRegex)) {
    return url;
}

Happy to put a PR together for this if the approach looks right — I wasn't sure whether you'd prefer returning it as-is or expanding it to the page's current protocol.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    low priorityIssues that are backlogged for when things are less busyneeds prIssues that are awaiting a PR to be submitted

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions