Skip to content

brand.json resolver: _canonicalize_url drops IPv6 brackets, and the jwks_origin_mismatch gate compares a canonical origin to a raw one #989

Description

@KonstantinMirin

Summary

Two origin-normalization defects in the brand.json resolver, both from hand-rolled netloc string work:

  1. _canonicalize_url rebuilds the netloc from parts.hostname, which strips IPv6 brackets and never re-adds them. A bracketed IPv6 brand.json URL is turned into a malformed URL, and the failure surfaces as an untyped SSRFValidationError escaping the resolver instead of a BrandJsonResolverError.
  2. The jwks_origin_mismatch gate compares a canonicalized brand origin against a raw agent origin, so cosmetic spelling differences in agent.url produce a spurious mismatch with an error message that blames a cross-origin trust pivot.

Both fail closed. Neither is a bypass — see "Direction" below, which matters because the second one sits on a security gate and reads worse than it is.

1. _canonicalize_url de-brackets IPv6 authorities

src/adcp/signing/brand_jwks.py:714-721:

host = parts.hostname or ""            # :714 — strips [ ] from IPv6
...
netloc = host if port is None else f"{host}:{port}"   # :720 — never re-adds them
return urlunsplit((scheme, netloc, parts.path, parts.query, ""))
>>> from adcp.signing.brand_jwks import _canonicalize_url
>>> _canonicalize_url("https://[::1]/x", allow_private=True)
'https://::1/x'

That string is then both the redirect-loop seen key (:551-553) and the URL handed to the transport builder at :569 and to client.get at :580. The builder rejects it:

>>> from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport
>>> build_async_ip_pinned_transport("https://::1/x", allow_private=True)
adcp.signing.jwks.SSRFValidationError: URI has no host: 'https://::1/x'

build_async_ip_pinned_transport at :569 is outside the try opened at :577, and the except SSRFValidationError at :581 only wraps client.get. So the exception escapes _fetch_brand_json untyped — callers that handle BrandJsonResolverError see an SSRFValidationError instead. (httpx would have refused the same URL with httpx.InvalidURL, which is not an httpx.HTTPError and so is not caught by :585 either.)

Marginal in frequency — brand.json URLs are normally DNS names — but the diagnostic is wrong and the exception type is not part of the resolver's contract.

Same function, second gap. Its docstring (:685-701) states it exists to close "trivial aliasing attacks (case-mismatched host, default-port elision)" against the seen set, and that a Python-only deployment would otherwise "fail open where JS fails closed". It does not strip the trailing FQDN-root dot and does not IDNA-encode, so it does not close those two aliases:

>>> _canonicalize_url("https://Brand.Example./x", allow_private=True)
'https://brand.example./x'                 # distinct `seen` entry from .../x
>>> _canonicalize_url("https://bücher.example/x", allow_private=True)
'https://bücher.example/x'                 # distinct from the xn-- spelling

max_redirects still bounds the chain, so this is not an unbounded loop — it means the depth cap, not the loop detector, is what stops it. Worth closing so the function meets its own stated contract, not because it is currently exploitable.

2. The jwks_origin_mismatch gate compares canonical to raw

src/adcp/signing/brand_jwks.py:895-898:

brand_parts = urlsplit(final_brand_url)
agent_origin = f"{agent_parts.scheme}://{agent_parts.netloc}"
brand_origin = f"{brand_parts.scheme}://{brand_parts.netloc}"
if agent_origin != brand_origin:
    raise BrandJsonResolverError("jwks_origin_mismatch", ...)

final_brand_url is the output of _canonicalize_url (:548, :641, :659:673): host lowercased, default port stripped, fragment stripped, userinfo rejected. agent_url is the raw agents[].url string from the document. So this is a byte comparison between a canonical value and an uncanonicalized one, and it uses raw .netloc — the one accessor in the module that keeps userinfo.

>>> from adcp.signing.brand_jwks import _default_jwks_uri
>>> BRAND = "https://brand.example/.well-known/brand.json"   # already canonicalized

>>> _default_jwks_uri("https://brand.example/agent", BRAND)
'https://brand.example/.well-known/jwks.json'

>>> _default_jwks_uri("https://Brand.Example/agent", BRAND)
BrandJsonResolverError: jwks_origin_mismatch
>>> _default_jwks_uri("https://brand.example:443/agent", BRAND)
BrandJsonResolverError: jwks_origin_mismatch
>>> _default_jwks_uri("https://brand.example./agent", BRAND)
BrandJsonResolverError: jwks_origin_mismatch
>>> _default_jwks_uri("https://user@brand.example/agent", BRAND)
BrandJsonResolverError: jwks_origin_mismatch

The IDN pair diverges the same way: a brand.json fetched from https://bücher.example/... produces brand_origin == "https://bücher.example", so an agent.url of https://xn--bcher-kva.example/agent mismatches despite naming the same host.

Direction: this fails closed, and cannot fail open

Stating this explicitly because the site is a security gate and the shape invites the opposite reading.

  • The brand side is always the output of _canonicalize_url: lowercase host, no userinfo, no default port, no fragment. The agent side is raw. A normalization divergence can therefore only make a raw string unequal to a canonical one. There is no input that makes two origins denoting different hosts compare equal, because equality here is byte equality against a value that is already in canonical form.
  • The return value at :907 is built from agent_origin, but only on the equality branch — where agent_origin is byte-identical to brand_origin. So the derived jwks_uri is always rooted at the canonical brand origin. No trust pivot is reachable through this path.

The honest severity is therefore interop / diagnostics, low: a publisher who writes agent.url: "https://Brand.Example/agent" or includes an explicit :443 in a brand.json served from https://brand.example/ loses the default-jwks_uri path, and the error text tells them their agent is on a different origin from their brand.json — which it is not. The documented escape hatch (declare an explicit jwks_uri) works, so this is recoverable, just confusing.

Suggested direction

Both findings have the same fix shape: derive the origin through the package's designated host canonicalizer rather than string-concatenating a netloc.

  • _canonicalize_url: re-bracket the host when it is an IPv6 literal before reassembling the netloc, and route the host through _idna_canonicalize.canonicalize_host (which strips one trailing dot, lowercases, short-circuits IP literals, and A-label-encodes). Note canonicalize_host returns IPv6 literals unbracketed, so bracket re-addition is the caller's job either way.
  • _default_jwks_uri: build both origins the same way — scheme.lower() + canonicalize_host(parts.hostname) + non-default port — rather than comparing raw .netloc on one side and a canonical string on the other. Using .hostname rather than .netloc also drops the userinfo that only the agent side can currently carry.
  • Also move build_async_ip_pinned_transport at :569 inside the try at :577, or wrap it, so an SSRF refusal on the transport-construction path surfaces as BrandJsonResolverError("fetch_failed", ...) like the one on the request path already does.

canonicalize_host raises on underscore hosts and over-long labels where these functions return them today; both sites are already fail-closed (they raise BrandJsonResolverError on bad input), so mapping the raise onto invalid_url is consistent with what is there.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions