#2544·mcp-use

client: sanitizeUrl rejects valid RFC 3986 bracketed IPv6 hostnames and double-encodes path/hash components

Author: rohith500Created Sep 14, 2026Updated Sep 14, 2026
LabelsbugdocumentationinvalidTypeScriptclientjavascript

Description

In @mcp-use/client, the URL sanitization utility sanitizeUrl in packages/client/src/auth/url.ts is used to validate and sanitize OAuth redirect URIs and authorization URLs (e.g. in OAuthSessionStore and useMcp).

Two defects exist in this utility:

  1. Rejection of Valid RFC 3986 / WHATWG IPv6 Bracketed Hostnames: Under RFC 3986 Section 3.2.2 and the WHATWG URL standard, IPv6 host literals are bracketed (e.g., http://[::1]:33418/callback or https://[2001:db8::1]/auth). However, sanitizeUrl performs the following hostname validation check:

    typescript
    if (url.hostname !== encodeURIComponent(url.hostname)) abort();

    Because encodeURIComponent("[::1]") produces "%5B%3A%3A1%5D", url.hostname !== encodeURIComponent(url.hostname) always evaluates to true for all IPv6 addresses. As a result, any OAuth loopback or remote endpoint hosted on an IPv6 address (including [::1]) throws: Error: Invalid url to pass to open(): http://[::1]:33418/callback

  2. Double Percent-Encoding of Path Segments and Fragment Identifiers: new URL(raw) already normalizes and percent-encodes paths. However, sanitizeUrl reapplies encodeURIComponent on path segments and the URL hash:

    typescript
    url.pathname =
      url.pathname.slice(0, 1) +
      encodeURIComponent(url.pathname.slice(1)).replace(/%2f/gi, "/");
    // ...
    url.hash = url.hash.slice(0, 1) + encodeURIComponent(url.hash.slice(1));

    Calling encodeURIComponent on an already percent-encoded string converts existing % characters into %25. For example:

    • /api/tenant%201/callback becomes /api/tenant%25201/callback (double-encoded space).
    • /docs/C%2B%2B becomes /docs/C%252B%252B (double-encoded +).
    • /user%2Fprofile becomes /user%2Fprofile initially or double-encodes other reserved sequences.
    • #/routes/profile becomes #%2Froutes%2Fprofile in hash fragments, breaking single-page application (SPA) client routing.

Steps to Reproduce

typescript
import { sanitizeUrl } from "@mcp-use/client/auth/url";

// Defect 1: IPv6 rejection
sanitizeUrl("http://[::1]:33418/callback");
// Throws: Error: Invalid url to pass to open(): http://[::1]:33418/callback

// Defect 2: Double percent-encoding
const sanitized = sanitizeUrl("https://example.com/api/tenant%201/callback");
console.log(sanitized);
// Outputs: "https://example.com/api/tenant%25201/callback" (Expected: "https://example.com/api/tenant%201/callback")

const spaRoute = sanitizeUrl("https://example.com/app#/routes/settings");
console.log(spaRoute);
// Outputs: "https://example.com/app#%2Froutes%2Fsettings" (Expected: "https://example.com/app#/routes/settings")

Expected Behavior

  1. Bracketed IPv6 hostnames adhering to RFC 3986 / WHATWG standards (such as [::1] or [2001:db8::1]) should be recognized as valid hostnames.
  2. Valid percent-encoded octets (%[0-9a-fA-F]{2}) in paths, query parameters, credentials, and fragments should be preserved without double-encoding.
  3. Fragment paths containing / (common in SPA routes) should not have their slashes percent-encoded.
  4. Malicious protocols (javascript:, data:, file:) and unsafe hostnames with control characters or whitespace should continue to be strictly rejected.

Suggested Fix

  1. Enhance hostname validation to allow bracketed IPv6 literals matching /^[0-9a-fA-F:.]+$/ within [ and ]:
    typescript
    function isValidHostname(hostname: string): boolean {
      if (hostname.startsWith("[") && hostname.endsWith("]")) {
        const ipv6 = hostname.slice(1, -1);
        return ipv6.length > 0 && /^[0-9a-fA-F:.]+$/.test(ipv6);
      }
      return hostname === encodeURIComponent(hostname);
    }
  2. Sanitize path segments and fragments by preserving existing valid %XX octets and only encoding unescaped characters:
    typescript
    function sanitizeEncodedComponent(
      value: string,
      encodeFn: (s: string) => string = encodeURIComponent
    ): string {
      return value.replace(
        /(%[0-9a-fA-F]{2})|([^%]+)|(%)/g,
        (_match, pct, plain, roguePct) => {
          if (pct) return pct;
          if (plain) return encodeFn(plain);
          if (roguePct) return "%25";
          return _match;
        }
      );
    }