Summary
Two origin-normalization defects in the brand.json resolver, both from hand-rolled netloc string work:
_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.
- 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.
Summary
Two origin-normalization defects in the
brand.jsonresolver, both from hand-rolled netloc string work:_canonicalize_urlrebuilds the netloc fromparts.hostname, which strips IPv6 brackets and never re-adds them. A bracketed IPv6brand.jsonURL is turned into a malformed URL, and the failure surfaces as an untypedSSRFValidationErrorescaping the resolver instead of aBrandJsonResolverError.jwks_origin_mismatchgate compares a canonicalized brand origin against a raw agent origin, so cosmetic spelling differences inagent.urlproduce 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_urlde-brackets IPv6 authoritiessrc/adcp/signing/brand_jwks.py:714-721:That string is then both the redirect-loop
seenkey (:551-553) and the URL handed to the transport builder at:569and toclient.getat:580. The builder rejects it:build_async_ip_pinned_transportat:569is outside thetryopened at:577, and theexcept SSRFValidationErrorat:581only wrapsclient.get. So the exception escapes_fetch_brand_jsonuntyped — callers that handleBrandJsonResolverErrorsee anSSRFValidationErrorinstead. (httpx would have refused the same URL withhttpx.InvalidURL, which is not anhttpx.HTTPErrorand so is not caught by:585either.)Marginal in frequency —
brand.jsonURLs 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 theseenset, 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:max_redirectsstill 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_mismatchgate compares canonical to rawsrc/adcp/signing/brand_jwks.py:895-898:final_brand_urlis the output of_canonicalize_url(:548,:641,:659→:673): host lowercased, default port stripped, fragment stripped, userinfo rejected.agent_urlis the rawagents[].urlstring 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.The IDN pair diverges the same way: a
brand.jsonfetched fromhttps://bücher.example/...producesbrand_origin == "https://bücher.example", so anagent.urlofhttps://xn--bcher-kva.example/agentmismatches 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.
_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.:907is built fromagent_origin, but only on the equality branch — whereagent_originis byte-identical tobrand_origin. So the derivedjwks_uriis 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:443in abrand.jsonserved fromhttps://brand.example/loses the default-jwks_uripath, 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 explicitjwks_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). Notecanonicalize_hostreturns 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.netlocon one side and a canonical string on the other. Using.hostnamerather than.netlocalso drops the userinfo that only the agent side can currently carry.build_async_ip_pinned_transportat:569inside thetryat:577, or wrap it, so an SSRF refusal on the transport-construction path surfaces asBrandJsonResolverError("fetch_failed", ...)like the one on the request path already does.canonicalize_hostraises on underscore hosts and over-long labels where these functions return them today; both sites are already fail-closed (they raiseBrandJsonResolverErroron bad input), so mapping the raise ontoinvalid_urlis consistent with what is there.