Skip to content

Commit b7ef1df

Browse files
fix(signing): canonicalize both origins in the brand.json jwks gate (#998)
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 #989.
1 parent 827e4f4 commit b7ef1df

2 files changed

Lines changed: 249 additions & 12 deletions

File tree

src/adcp/signing/brand_jwks.py

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545
from urllib.parse import urlsplit, urlunsplit
4646

4747
import httpx
48+
import idna
4849

50+
from adcp.signing._idna_canonicalize import canonicalize_host
4951
from adcp.signing.jwks import (
5052
AsyncCachingJwksResolver,
5153
AsyncJwksFetcher,
@@ -566,7 +568,17 @@ async def _fetch_brand_json(
566568
if client_factory is not None:
567569
client_cm = client_factory(url)
568570
else:
569-
transport = build_async_ip_pinned_transport(url, allow_private=allow_private)
571+
# The transport builder resolves + validates the host up
572+
# front, so it — not the later request — is where an SSRF
573+
# refusal surfaces. It must sit inside the same handler as
574+
# the request or the refusal escapes the resolver's
575+
# documented error contract as a raw SSRFValidationError.
576+
try:
577+
transport = build_async_ip_pinned_transport(url, allow_private=allow_private)
578+
except SSRFValidationError as exc:
579+
raise BrandJsonResolverError(
580+
"fetch_failed", f"brand.json URL failed SSRF check: {exc}"
581+
) from exc
570582
client_cm = httpx.AsyncClient(
571583
transport=transport,
572584
timeout=timeout_seconds,
@@ -691,6 +703,19 @@ def _canonicalize_url(raw: str, *, allow_private: bool) -> str:
691703
692704
* Scheme lowercased.
693705
* Host lowercased (``urlsplit`` does NOT do this — we do it).
706+
* Trailing FQDN-root dot stripped (``brand.example.`` and
707+
``brand.example`` are the same host).
708+
* Unicode U-labels encoded to A-labels (``bücher.example`` →
709+
``xn--bcher-kva.example``), via the package-wide UTS#46
710+
convention in :mod:`adcp.signing._idna_canonicalize`. A host the
711+
IDNA encoder refuses (e.g. an underscore label) is rejected with
712+
``invalid_url`` rather than passed through.
713+
* IP literals normalized to their canonical form, and IPv6
714+
literals re-bracketed. ``urlsplit(...).hostname`` returns IPv6
715+
hosts *de-bracketed*, so rebuilding the authority from it
716+
without re-adding brackets emits ``https://::1/x`` — a string
717+
with no parseable host, and with a non-default port a string
718+
whose port can no longer be separated from the address.
694719
* Default port (443 for https, 80 for http) stripped.
695720
* Fragments stripped — they aren't sent on the wire and must not
696721
smuggle loop-detection aliases into ``seen``.
@@ -699,6 +724,10 @@ def _canonicalize_url(raw: str, *, allow_private: bool) -> str:
699724
``https://x.example/`` as distinct strings; the JS-side resolver
700725
canonicalizes both via ``new URL``, so a Python-only deployment
701726
would fail open where JS fails closed.
727+
728+
The returned string is required to be a URL the transport layer
729+
accepts: it is fed straight to ``build_async_ip_pinned_transport``
730+
and ``client.get`` on every redirect hop.
702731
"""
703732
try:
704733
parts = urlsplit(raw)
@@ -711,9 +740,21 @@ def _canonicalize_url(raw: str, *, allow_private: bool) -> str:
711740
scheme = parts.scheme.lower()
712741
if scheme != "https" and not (allow_private and scheme == "http"):
713742
raise BrandJsonResolverError("invalid_url", "brand.json URL must use https://")
714-
host = parts.hostname or ""
715-
if not host:
743+
raw_host = parts.hostname or ""
744+
if not raw_host:
716745
raise BrandJsonResolverError("invalid_url", "brand.json URL has no host")
746+
try:
747+
host = canonicalize_host(raw_host)
748+
except (idna.IDNAError, UnicodeError) as exc:
749+
raise BrandJsonResolverError(
750+
"invalid_url", f"brand.json URL host is not a valid IDNA name: {exc}"
751+
) from exc
752+
# ``canonicalize_host`` returns IPv6 literals UNBRACKETED (its step
753+
# 3 short-circuits to ``str(ipaddress.ip_address(...))``); putting
754+
# the brackets back is the caller's job. A canonicalized DNS name
755+
# never contains ':', so this is an unambiguous IPv6 test.
756+
if ":" in host:
757+
host = f"[{host}]"
717758
port = parts.port
718759
if port is not None and port == _DEFAULT_PORTS.get(scheme):
719760
port = None
@@ -874,6 +915,40 @@ def _pick_agent(
874915
return _SelectedAgent(url=url, jwks_uri=jwks_uri)
875916

876917

918+
def _canonical_origin(raw: str, label: str) -> str:
919+
"""`scheme://host[:port]` in the same canonical form on both sides.
920+
921+
Shares its host handling with :func:`_canonicalize_url` -- same
922+
`canonicalize_host`, same re-bracketing, same default-port elision -- so an
923+
origin equality test cannot be defeated by spelling. Deliberately built
924+
from `.hostname` rather than `.netloc`: `.netloc` is the one accessor that
925+
retains userinfo, and `https://user@brand.example` must compare equal to
926+
`https://brand.example` rather than pivoting trust onto a different string.
927+
"""
928+
try:
929+
parts = urlsplit(raw)
930+
except ValueError as exc:
931+
raise BrandJsonResolverError("invalid_url", f"{label} is not a valid URL") from exc
932+
if not parts.scheme or not parts.netloc:
933+
raise BrandJsonResolverError("invalid_url", f"{label} is not a valid URL")
934+
raw_host = parts.hostname or ""
935+
if not raw_host:
936+
raise BrandJsonResolverError("invalid_url", f"{label} has no host")
937+
try:
938+
host = canonicalize_host(raw_host)
939+
except (idna.IDNAError, UnicodeError) as exc:
940+
raise BrandJsonResolverError(
941+
"invalid_url", f"{label} host is not a valid IDNA name: {exc}"
942+
) from exc
943+
if ":" in host: # canonicalize_host returns IPv6 unbracketed
944+
host = f"[{host}]"
945+
scheme = parts.scheme.lower()
946+
port = parts.port
947+
if port is not None and port == _DEFAULT_PORTS.get(scheme):
948+
port = None
949+
return f"{scheme}://{host}" if port is None else f"{scheme}://{host}:{port}"
950+
951+
877952
def _default_jwks_uri(agent_url: str, final_brand_url: str) -> str:
878953
"""Spec fallback: when ``agent.jwks_uri`` is absent, default to
879954
``<agent_origin>/.well-known/jwks.json``.
@@ -886,15 +961,16 @@ def _default_jwks_uri(agent_url: str, final_brand_url: str) -> str:
886961
agent on a different origin from their brand.json MUST declare an
887962
explicit ``jwks_uri``.
888963
"""
889-
try:
890-
agent_parts = urlsplit(agent_url)
891-
except ValueError as exc:
892-
raise BrandJsonResolverError("invalid_url", "agent.url is not a valid URL") from exc
893-
if not agent_parts.scheme or not agent_parts.netloc:
894-
raise BrandJsonResolverError("invalid_url", "agent.url is not a valid URL")
895-
brand_parts = urlsplit(final_brand_url)
896-
agent_origin = f"{agent_parts.scheme}://{agent_parts.netloc}"
897-
brand_origin = f"{brand_parts.scheme}://{brand_parts.netloc}"
964+
agent_origin = _canonical_origin(agent_url, "agent.url")
965+
# ``final_brand_url`` has already been through ``_canonicalize_url``, but
966+
# canonicalizing it again is required rather than merely tidy: the two
967+
# sides of this comparison MUST be produced by the same function or the
968+
# check compares a canonical string to a raw one. That asymmetry is the
969+
# defect -- a publisher spelling the same origin identically on both sides
970+
# (a U-label, a trailing root dot, a default port) got told their agent was
971+
# on a different origin from their brand.json, which it was not.
972+
# Re-canonicalizing is idempotent, so this costs nothing.
973+
brand_origin = _canonical_origin(final_brand_url, "brand.json URL")
898974
if agent_origin != brand_origin:
899975
raise BrandJsonResolverError(
900976
"jwks_origin_mismatch",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Proves ``_canonicalize_url`` emits a URL the transport layer accepts.
2+
3+
The brand.json walk canonicalizes every hop URL before it is used for
4+
three things: the outbound fetch, the redirect-loop ``seen`` key, and
5+
the origin gate in ``_default_jwks_uri``. All three break when the
6+
canonicalizer rebuilds the authority from the *de-bracketed* IPv6
7+
literal that ``urlsplit(...).hostname`` returns:
8+
9+
* ``https://[::1]/x`` became ``https://::1/x`` — a string with no
10+
parseable host, which the IP-pinned transport builder refuses.
11+
* ``https://[2001:db8::1]:8443/x`` became ``https://2001:db8::1:8443/x``
12+
— re-parsing that raises because the port is no longer separable.
13+
* A brand.json and an ``agent.url`` on the same IPv6 host compared
14+
unequal because one side was bracketed and the other was not.
15+
16+
These tests pin the round-trip contract: canonicalizer output must be
17+
a well-formed URL that re-parses to the same host and port, and that
18+
the SSRF-validating transport builder accepts.
19+
20+
They also pin the fetch path's exception contract — a transport-side
21+
SSRF refusal must surface as ``BrandJsonResolverError("fetch_failed")``,
22+
not as a raw ``SSRFValidationError`` leaking through the resolver.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from urllib.parse import urlsplit
28+
29+
import pytest
30+
31+
from adcp.signing.brand_jwks import (
32+
BrandJsonResolverError,
33+
_canonicalize_url,
34+
_default_jwks_uri,
35+
_fetch_brand_json,
36+
)
37+
from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport
38+
39+
# ----- IPv6 bracket preservation -----
40+
41+
42+
def test_canonicalize_preserves_ipv6_brackets() -> None:
43+
assert _canonicalize_url("https://[::1]/x", allow_private=True) == "https://[::1]/x"
44+
45+
46+
def test_canonicalize_ipv6_with_port_stays_unambiguous() -> None:
47+
result = _canonicalize_url("https://[2001:DB8:0:0::1]:8443/x", allow_private=True)
48+
assert result == "https://[2001:db8::1]:8443/x"
49+
reparsed = urlsplit(result)
50+
assert reparsed.hostname == "2001:db8::1"
51+
assert reparsed.port == 8443
52+
53+
54+
def test_canonicalize_ipv6_strips_default_port() -> None:
55+
assert (
56+
_canonicalize_url("https://[2001:db8::1]:443/x", allow_private=True)
57+
== "https://[2001:db8::1]/x"
58+
)
59+
60+
61+
def test_canonicalized_ipv6_url_is_accepted_by_the_transport_builder() -> None:
62+
url = _canonicalize_url("https://[::1]/x", allow_private=True)
63+
# Must not raise: the canonicalizer's output is fed straight to this
64+
# builder at every redirect hop.
65+
build_async_ip_pinned_transport(url, allow_private=True)
66+
67+
68+
def test_canonicalize_ipv6_same_origin_agent_gets_default_jwks_uri() -> None:
69+
brand = _canonicalize_url("https://[::1]/.well-known/brand.json", allow_private=True)
70+
assert _default_jwks_uri("https://[::1]/agent", brand) == "https://[::1]/.well-known/jwks.json"
71+
72+
73+
# ----- host aliasing closed by routing through the shared canonicalizer -----
74+
75+
76+
def test_canonicalize_strips_trailing_root_dot() -> None:
77+
assert _canonicalize_url("https://Brand.Example./x", allow_private=False) == (
78+
"https://brand.example/x"
79+
)
80+
81+
82+
def test_canonicalize_encodes_u_label_to_a_label() -> None:
83+
assert _canonicalize_url("https://bücher.example/x", allow_private=False) == (
84+
"https://xn--bcher-kva.example/x"
85+
)
86+
87+
88+
def test_canonicalize_rejects_host_the_idna_encoder_refuses() -> None:
89+
with pytest.raises(BrandJsonResolverError) as exc:
90+
_canonicalize_url("https://foo_bar.example/x", allow_private=False)
91+
assert exc.value.code == "invalid_url"
92+
93+
94+
# ----- fetch path exception contract -----
95+
96+
97+
async def test_fetch_brand_json_maps_transport_ssrf_refusal_to_resolver_error() -> None:
98+
with pytest.raises(BrandJsonResolverError) as exc:
99+
await _fetch_brand_json(
100+
start_url="https://localhost/.well-known/brand.json",
101+
current_etag=None,
102+
max_redirects=3,
103+
allow_private=False,
104+
timeout_seconds=1.0,
105+
)
106+
assert exc.value.code == "fetch_failed"
107+
108+
109+
# ---------- the origin gate must compare like with like ----------
110+
111+
112+
@pytest.mark.parametrize(
113+
("agent_url", "brand_url"),
114+
[
115+
("https://Brand.Example/agent", "https://brand.example/brand.json"),
116+
("https://brand.example:443/agent", "https://brand.example/brand.json"),
117+
("https://brand.example./agent", "https://brand.example/brand.json"),
118+
("https://user@brand.example/agent", "https://brand.example/brand.json"),
119+
("https://bücher.example/agent", "https://bücher.example/brand.json"),
120+
("https://[::1]/agent", "https://[::1]/brand.json"),
121+
],
122+
ids=["case", "default-port", "root-dot", "userinfo", "u-label", "ipv6"],
123+
)
124+
def test_same_origin_spelled_differently_still_defaults_the_jwks_uri(
125+
agent_url: str, brand_url: str
126+
) -> None:
127+
"""The gate compares origins, not spellings.
128+
129+
``final_brand_url`` arrives already canonicalized while ``agent.url`` is
130+
raw as published, so building one side with ``.netloc`` and the other from
131+
the canonicalizer compares a canonical string to a raw one. Every row here
132+
is a publisher who spelled the SAME origin on both sides and was told their
133+
agent lives somewhere else — the confusing diagnostic this gate exists to
134+
avoid, produced by the gate itself.
135+
136+
``userinfo`` is in the list for a sharper reason: ``.netloc`` is the one
137+
accessor that retains it, so ``https://user@brand.example`` compared
138+
unequal to ``https://brand.example`` and the trust decision turned on a
139+
credential that is not part of the origin at all.
140+
"""
141+
assert _default_jwks_uri(agent_url, brand_url).endswith("/.well-known/jwks.json")
142+
143+
144+
def test_cross_origin_agent_is_still_rejected() -> None:
145+
"""The fence the gate exists for, unmoved by the canonicalization above.
146+
147+
An attacker-controlled brand.json naming an agent on another origin must
148+
not make that origin's JWKS authoritative. Canonicalizing both sides closes
149+
spelling differences; it must not close genuine origin differences.
150+
"""
151+
with pytest.raises(BrandJsonResolverError) as exc:
152+
_default_jwks_uri("https://evil.example/agent", "https://brand.example/brand.json")
153+
assert exc.value.code == "jwks_origin_mismatch"
154+
155+
156+
def test_ipv6_agent_origin_keeps_its_brackets_in_the_defaulted_uri() -> None:
157+
"""The defaulted jwks_uri is fetched, so it must re-parse to the same host."""
158+
uri = _default_jwks_uri("https://[2001:db8::1]:8443/agent", "https://[2001:db8::1]:8443/b.json")
159+
assert uri == "https://[2001:db8::1]:8443/.well-known/jwks.json"
160+
assert urlsplit(uri).hostname == "2001:db8::1"
161+
assert urlsplit(uri).port == 8443

0 commit comments

Comments
 (0)