Skip to content

fix(signing): canonicalize both origins in the brand.json jwks gate - #998

Merged
bokelley merged 1 commit into
adcontextprotocol:mainfrom
KonstantinMirin:fix/brand-jwks-ipv6-brackets
Jul 29, 2026
Merged

fix(signing): canonicalize both origins in the brand.json jwks gate#998
bokelley merged 1 commit into
adcontextprotocol:mainfrom
KonstantinMirin:fix/brand-jwks-ipv6-brackets

Conversation

@KonstantinMirin

Copy link
Copy Markdown
Collaborator

Fixes #989 — both halves.

Independent of the signing stack — branched off main, touches no file #980/#985/#987/#993 touch.

Two defects, and they are the same defect from two sides

1. _canonicalize_url dropped IPv6 brackets. It rebuilt the authority from urlsplit(...).hostname, which returns IPv6 literals de-bracketed:

https://[::1]/x            ->  https://::1/x              (no parseable host)
https://[2001:db8::1]:8443/x ->  https://2001:db8::1:8443/x (port no longer separable)

Both are fed straight to build_async_ip_pinned_transport on every redirect hop, so the first was refused outright and the second re-parsed wrongly.

2. _default_jwks_uri compared a canonical origin against a raw one. final_brand_url has been through the canonicalizer; agent.url is raw as published. So a publisher spelling the same origin identically on both sides was told their agent lives somewhere else:

agent.url / brand.json (same origin, same spelling) before after
https://Brand.Example/… jwks_origin_mismatch ✅ defaults
https://brand.example:443/… jwks_origin_mismatch ✅ defaults
https://brand.example./… jwks_origin_mismatch ✅ defaults
https://user@brand.example/… jwks_origin_mismatch ✅ defaults
https://bücher.example/… jwks_origin_mismatch ✅ defaults
https://evil.example/… vs brand.example jwks_origin_mismatch still rejected

That is exactly the confusing diagnostic the gate exists to avoid — produced by the gate itself.

Why they had to be fixed together

Fixing only #1 makes #2 worse. A stronger canonicalizer on one side of an asymmetric comparison widens the gap. An earlier draft of this change did only the bracket fix, and it broke three publisher configurations that work on main today — a U-label on both sides, a trailing root dot on both sides, and an underscore host. Anyone reviewing the bracket fix in isolation should know it is not safely separable.

Both sides now run through one _canonical_origin, sharing host handling with _canonicalize_url: same canonicalize_host, same re-bracketing, same default-port elision.

It is built from .hostname, not .netloc. .netloc is the one accessor that retains userinfo, so https://user@brand.example compared unequal to https://brand.example — a trust decision turning on a credential that is not part of an origin at all.

The security fence is unmoved

An attacker-controlled brand.json naming agent.url on another origin is still rejected with jwks_origin_mismatch, and test_cross_origin_agent_is_still_rejected pins it. Canonicalizing closes spelling differences, not origin differences — worth stating explicitly, because "we made an origin comparison more permissive" is the shape of a real vulnerability and this one is not.

Also in scope, from the issue's suggested direction: build_async_ip_pinned_transport moves inside the fetch try block, so a transport-side SSRF refusal surfaces as fetch_failed instead of escaping the resolver as a raw SSRFValidationError.

On the issue's severity framing

#989 describes the origin comparison as a diagnostics/interop problem, not a trust-pivot, and that is correct — I re-verified it. The comparison is byte equality against an already-canonical value, so a normalization divergence can only make the raw side unequal; no input makes two different origins compare equal, and the defaulted jwks_uri is built on the equality branch where both strings are identical. The user-visible cost was publishers losing the default-jwks_uri path and being told something false about their own configuration.

Test plan

make ci-local: 5913 passed, 0 failed, 41 skipped, 1 xfailed.

17 tests in tests/conformance/signing/test_brand_jwks_canonicalization.py.

For the origin-gate tests specifically: 4 of the new ones fail with src/adcp/signing/brand_jwks.py reverted to base and the test file kept — verified by stashing only the source file, since stashing everything removes the tests too and proves nothing. The remaining rows (u-label, ipv6, and the cross-origin fence) pass either way and are regression guards rather than new coverage; calling that out rather than claiming nine red tests.

Two defects, and they are the same defect seen from two sides.

`_canonicalize_url` rebuilt the authority from `urlsplit(...).hostname`, which
returns IPv6 literals DE-bracketed. `https://[::1]/x` became `https://::1/x` --
a string with no parseable host -- and `https://[2001:db8::1]:8443/x` became
`https://2001:db8::1:8443/x`, where the port is no longer separable from the
address. Both are fed straight to the IP-pinned transport builder on every
redirect hop, so the first refused the URL and the second re-parsed wrongly.

`_default_jwks_uri` then compared a CANONICAL origin against a RAW one:
`final_brand_url` has been through the canonicalizer, `agent.url` is raw as
published. A publisher who spelled the same origin identically on both sides --
mixed case, an explicit :443, a trailing root dot, a U-label -- was told their
agent was on a different origin from their brand.json, which it was not. That
is precisely the confusing diagnostic the gate exists to avoid, produced by the
gate itself.

Fixing only the first would have made the second WORSE: a stronger
canonicalizer on one side of an asymmetric comparison widens the gap. Both
sides now run through one `_canonical_origin`.

It is built from `.hostname`, not `.netloc`. `.netloc` is the one accessor that
retains userinfo, so `https://user@brand.example` compared unequal to
`https://brand.example` -- a trust decision turning on a credential that is not
part of an origin at all.

The cross-origin fence is unmoved: an attacker-controlled brand.json naming an
agent on another origin is still rejected with `jwks_origin_mismatch`.
Canonicalizing closes spelling differences, not origin differences.

Also moves `build_async_ip_pinned_transport` inside the fetch try-block so a
transport-side SSRF refusal surfaces as `fetch_failed` rather than escaping the
resolver as a raw `SSRFValidationError`.

Fixes adcontextprotocol#989.
@aao-ipr-bot

aao-ipr-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@bokelley bokelley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against current main. IPv6 authorities remain parseable after canonicalization, both sides of the default-JWKS origin comparison now use the same canonical form, and the cross-origin fence remains intact. Focused integration coverage passed.

@bokelley
bokelley merged commit b7ef1df into adcontextprotocol:main Jul 29, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants