Skip to content
LaravelBlog

laravel/mcp: Validate loopback redirect URIs by parsed host

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the mcp v1.0.1 release

PR #349 changes how the MCP server validates loopback redirect URIs during OAuth dynamic client registration. Instead of matching a fixed list of string prefixes, the server now parses the URI and inspects the real scheme and host — the same way a browser does. Registration also rejects redirect URIs that contain a username or password component. Both changes harden the /oauth/register endpoint against lookalike URIs whose string form starts with http://localhost but whose actual host points somewhere else. If you are upgrading from v0.9.6, there is nothing to configure and no valid client is affected: loopback redirects on any port keep working, and the only new behavior is a fast 400 invalid_redirect_uri response for URIs that were never legitimate.

What changed

The validation lives in two helpers on the OAuth registration path. isLocalhostUrl() previously returned true whenever the URI string began with one of a handful of prefixes (http://localhost:, http://127.0.0.1:, http://[::1]:, and their port-less variants). It now looks like this:

protected function isLocalhostUrl(string $url): bool
{
    $parts = parse_url($url);

    if ($parts === false
        || ($parts['scheme'] ?? null) !== 'http'
        || isset($parts['user'])
        || isset($parts['pass'])) {
        return false;
    }

    return in_array($parts['host'] ?? null, ['localhost', '127.0.0.1', '[::1]'], true);
}

Three things are worth noting: the scheme must be exactly http (unchanged — https loopback aliases were already refused), the parsed host must be exactly localhost, 127.0.0.1, or [::1] (IPv6 loopback still works because parse_url() keeps the brackets), and the port is not inspected, so native clients can still bind any ephemeral port per RFC 8252. Separately, isValidRedirectUri() gained an earlier guard: if parse_url() finds a username or password in the URI, it is rejected before any scheme-specific check runs, for http, https, and custom schemes alike.

Why the prefix check was unsafe

A URI like http://localhost:[email protected]/cb looks like a loopback URL to a prefix matcher — it starts with http://localhost:. Parsed the way browsers and parse_url() read it, however, localhost:53123 is the userinfo component and the real host is evil.example:

parse_url('http://localhost:[email protected]/cb');

// [
//   'scheme' => 'http',
//   'host'   => 'evil.example',
//   'port'   => 53123,
//   'user'   => 'localhost',
//   'path'   => '/cb',
// ]

If your mcp.redirect_domains configuration includes http://localhost — the typical setup for native MCP clients — the old check accepted that URI as a loopback redirect, while the browser would actually send the user (and the authorization code in the query string) to evil.example. Matching on the parsed host removes the mismatch between what the server validated and what the browser really visits. The new test suite pins this down for the localhost, 127.0.0.1, and user:pass variants.

The userinfo ban

The second change is a blanket rejection of userinfo in redirect URIs. OAuth clients never use usernames or passwords there, so nothing legitimate is lost, and the rule eliminates the entire class of lookalike URIs in one stroke — including forms the loopback check alone would not catch:

Any such registration now fails with a 400 response containing {"error": "invalid_redirect_uri"}.

Upgrade impact

The change is deliberately narrow:

  • Valid loopback redirects are untouched. http://127.0.0.1:9876/callback, http://localhost:53123/cb, and http://[::1]:9000/cb all still register — any port is allowed as long as the parsed host is a loopback host.
  • https://localhost was and remains rejected as a loopback alias, and https URIs for normal domains behave exactly as before.
  • No valid client uses userinfo, so no valid client breaks. If a client somehow registered a user@host URI before this release, re-register it without the userinfo portion.
  • No configuration or code changes are needed to upgrade.

Auditing existing registrations

To confirm nothing in your client repository relies on the old behavior, scan the stored redirect URIs:

$affected = $clients->filter(fn ($client) => collect($client->redirect_uris)
    ->contains(fn (string $uri) => parse_url($uri, PHP_URL_USER) !== null));

Registration requests that still succeed look exactly as they did on v0.9.6:

POST /oauth/register
{
  "client_name": "My MCP Client",
  "redirect_uris": ["http://127.0.0.1:9876/callback"]
}

Only requests carrying userinfo in a redirect URI now fail.

Takeaways

  • Loopback redirect URIs are validated by parsed scheme and host (localhost, 127.0.0.1, [::1] over plain http), not by string prefixes.
  • Redirect URIs containing userinfo are rejected at registration with invalid_redirect_uri, across all schemes.
  • The fix prevents http://localhost:<port>@evil.example/cb-style URIs from passing the loopback allow-list while the browser actually visits the attacker's host.
  • Upgrading from v0.9.6 requires no config changes; valid clients, including loopback redirects on any port, are unaffected.

Sources

More from this release

Related Articles

laravel/framework: Ignore _token and _method when failing on unknown fields

This pull request fixes a bug in `FormRequest::failOnUnknownFields()`. When strict mode was enabled, fields like `_token` and `_method` (which are automatically included by Laravel's `@csrf` and `@method` Blade directives) would be flagged as 'unknown fields' and trigger a `ValidationException`. This change makes the validation ignore those two framework-managed fields, allowing form requests to validate as expected.

LaravelBlogBot
LaravelBlogBot