Skip to content

cliUrl / cli_url parser rejects IPv6 addresses (including bracketed [::1]:port form) #1137

Description

@007bsd

Summary

The cliUrl (Node, .NET) / cli_url (Python) / CLIUrl (Go) URL parser rejects IPv6 addresses in every standard form, including the canonical bracketed [::1]:8080 notation. The error message ("Invalid cli_url format") doesn't suggest IPv6 isn't supported, so users debugging it have no obvious next step.

This is a feature gap, not a security issue.

Affected versions

main at commit dd2dcbc439256acfb9feb2cff07c0b9c820091b8 (current HEAD as of 2026-04-26). All language SDKs share the same parsing logic and exhibit the same gap.

Affected source

  • python/copilot/client.py:944-986_parse_cli_url
  • nodejs/src/client.ts:351-376parseCliUrl
  • go/client.go:256-283parseCliUrl

Reproduction

from copilot.client import CopilotClient, ExternalServerConfig
c = CopilotClient(ExternalServerConfig(url="localhost:1"))

# IPv6 loopback with port — standard URL bracketed form
c._parse_cli_url("[::1]:8080")
# → ValueError: Invalid cli_url format: [::1]:8080

# IPv6 loopback alone
c._parse_cli_url("::1")
# → ValueError: Invalid cli_url format: ::1

# Link-local IPv6 with port
c._parse_cli_url("fe80::1:8080")
# → ValueError: Invalid cli_url format: fe80::1:8080

# Same with http:// prefix (which the parser otherwise accepts)
c._parse_cli_url("http://[::1]:8080")
# → ValueError: Invalid cli_url format: http://[::1]:8080

For comparison, IPv4 works as expected:

c._parse_cli_url("127.0.0.1:8080")
# → ('127.0.0.1', 8080)

Root cause

The Python parser splits on : and asserts exactly two parts:

# python/copilot/client.py:973-975
parts = clean_url.split(":")
if len(parts) != 2:
    raise ValueError(f"Invalid cli_url format: {url}")

IPv6 addresses contain multiple colons, so any IPv6 form produces len(parts) > 2 and is rejected. Bracketed-form parsing (the standard way to disambiguate host:port when the host itself contains colons, per RFC 3986 §3.2.2) is not implemented.

The Node and Go parsers have the same shape (split(":") / strings.Cut) and the same gap.

Impact

  • IPv6-only environments cannot use cliUrl to connect to a backend CLI server.
  • The error message does not hint that IPv6 is unsupported, so users hit a dead end debugging.
  • The documented backend-services and scaling deployment patterns implicitly require IPv4.

Suggested fix

Use the language's URL parser instead of hand-rolled splitting:

Python:

from urllib.parse import urlsplit
def _parse_cli_url(self, url: str) -> tuple[str, int]:
    if "://" not in url:
        url = "tcp://" + url
    parts = urlsplit(url)
    if parts.hostname is None or parts.port is None:
        raise ValueError(f"Invalid cli_url format: {url}")
    return (parts.hostname, parts.port)

urllib.parse.urlsplit handles bracketed IPv6 correctly, returns hostname already unbracketed, and rejects out-of-range ports.

Node has URL built in; Go has net/url and net.SplitHostPort — both handle IPv6 brackets natively.

Related minor issues found while investigating

These are smaller and could be folded into the same fix or filed separately:

  1. Leading whitespace in host accepted silently. _parse_cli_url(" localhost:8080") returns (" localhost", 8080) — the literal space is preserved as part of the host, then fails at socket.connect() with a confusing DNS error. Should strip() or reject.

  2. Misleading error category for URL paths. _parse_cli_url("http://localhost:8080/path") returns "Invalid port in cli_url" — the actual problem is the trailing /path, not the port.

  3. Empty host with colon prefix accepted. _parse_cli_url(":8080") returns ("localhost", 8080). Possibly intentional, but undocumented and inconsistent with the spec.

Verification script

Self-contained reproducer covering all cases above. Save as probe.py inside a checkout of github/copilot-sdk and run from the repo root:

"""Demonstrates the IPv6 parsing gap in _parse_cli_url across all input forms."""
import os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "python"))

from copilot.client import CopilotClient, ExternalServerConfig

cases = [
    # IPv4 baseline (works)
    ("127.0.0.1:8080",             "IPv4 baseline"),
    ("localhost:8080",             "hostname baseline"),
    # IPv6 — the gap
    ("[::1]:8080",                 "IPv6 loopback, bracketed (RFC 3986 standard form)"),
    ("::1",                        "IPv6 loopback bare"),
    ("fe80::1:8080",               "IPv6 link-local with port"),
    ("http://[::1]:8080",          "IPv6 with http:// prefix"),
    ("[2001:db8::1]:443",          "IPv6 documentation prefix"),
    # Minor adjacent issues
    (" localhost:8080",            "leading space in host (silently accepted, breaks later)"),
    ("http://localhost:8080/path", "URL with path (misleading error)"),
    (":8080",                      "empty host (silently defaults to localhost)"),
]

c = CopilotClient(ExternalServerConfig(url="localhost:1"))
print(f"{'INPUT':<32s} {'RESULT':<55s} NOTE")
print(f"{'-'*32} {'-'*55} ----")
for url, note in cases:
    try:
        h, p = c._parse_cli_url(url)
        result = f"({h!r}, {p})"
    except Exception as e:
        result = f"{type(e).__name__}: {e}"
    print(f"{url!r:<32s} {result:<55s} {note}")

Expected output:

INPUT                            RESULT                                                  NOTE
-------------------------------- ------------------------------------------------------- ----
'127.0.0.1:8080'                 ('127.0.0.1', 8080)                                     IPv4 baseline
'localhost:8080'                 ('localhost', 8080)                                     hostname baseline
'[::1]:8080'                     ValueError: Invalid cli_url format: [::1]:8080          IPv6 loopback, bracketed (RFC 3986 standard form)
'::1'                            ValueError: Invalid cli_url format: ::1                 IPv6 loopback bare
'fe80::1:8080'                   ValueError: Invalid cli_url format: fe80::1:8080        IPv6 link-local with port
'http://[::1]:8080'              ValueError: Invalid cli_url format: http://[::1]:8080   IPv6 with http:// prefix
'[2001:db8::1]:443'              ValueError: Invalid cli_url format: [2001:db8::1]:443   IPv6 documentation prefix
' localhost:8080'                (' localhost', 8080)                                    leading space in host (silently accepted, breaks later)
'http://localhost:8080/path'     ValueError: Invalid port in cli_url: http://localhost:8080/path URL with path (misleading error)
':8080'                          ('localhost', 8080)                                     empty host (silently defaults to localhost)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions