Skip to content

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

@007bsd

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions