Skip to content

Commit a2194fb

Browse files
fix(server): unify divergent host normalizers behind one helper
Three separate implementations of "normalize a Host header for tenant lookup" existed -- in the subdomain router, the tenant registry, and the v3 reference seller example. They disagreed with each other, all three mangled IPv6 authorities, and one leaked userinfo into the lookup key. The IPv6 case was not merely cosmetic: splitting on the first colon reduced every bracketed literal to the key "[", so two distinct IPv6 tenants collapsed onto the same entry. That is cross-tenant mis-resolution, not a fail-closed 404. All three now route through one `normalize_host_key`, which delegates to `canonicalize_host` rather than adding a fourth hand-rolled normalizer. The helper NEVER raises. Host is attacker-controlled, so a canonicalizer that raised would turn a clean 404 into a 500 -- it falls back to the lower-cased input on anything IDNA cannot process. 26 hostile shapes are pinned by test, and every output is idempotent under a second pass, which is load-bearing: InMemory registration keys are normalized at registration and again at lookup. ADOPTER-VISIBLE CHANGES, none of them isolation-breaking: - Host values that previously 404'd now resolve to the bare host's tenant: `user@acme.example.com`, `acme.example.com/x` and `acme.example.com:abc` all key to `acme.example.com`. Same tenant, so no cross-tenant leak, but it is a Host-header parsing widening and matters to anyone whose upstream cache or WAF keys on the raw Host. - UTS-46 folding means compatibility forms now match: `ACME.example.com` reaches the tenant registered as `acme.example.com`. Correct IDNA behaviour, but if two registration keys fold together `InMemorySubdomainTenantRouter` keeps the last silently. Pre-existing for case variants; the folding enlarges the silently-colliding class. - Per-request normalization cost went 0.1us -> 14us, noise next to the DNS lookup it precedes. `canonicalize_host` is reached only on the non-ASCII path, and that placement is load-bearing rather than tidy. It lives in `adcp.signing`, whose package import pulls 30 modules (~0.2s locally, more on a cold runner). At module level that cost landed on EVERY `import adcp.server` and pushed four storyboard examples past the runner's 30s readiness budget -- including three that never touch tenant routing. Deferring it into the function fixed those three but not the one example that builds a router, which merely moved the cost to boot. For all-ASCII input `canonicalize_host` either returns exactly the value the fast path already computed, or raises -- and every raise falls back to exactly that value. The answer is identical either way, so the fast path costs no correctness. UTS-46 folding still applies to genuinely non-ASCII hosts. Fixes #990.
1 parent 1ca7e07 commit a2194fb

5 files changed

Lines changed: 254 additions & 46 deletions

File tree

examples/v3_reference_seller/src/tenant_router.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424

2525
from adcp.server import Tenant
2626

27+
# Not yet re-exported from ``adcp.server``; import from the submodule.
28+
from adcp.server.tenant_router import normalize_host_key
29+
2730
if TYPE_CHECKING:
2831
from sqlalchemy.ext.asyncio import async_sessionmaker
2932

@@ -49,13 +52,15 @@ def __init__(
4952
self._cache_lock = asyncio.Lock()
5053

5154
async def resolve(self, host: str) -> Tenant | None:
52-
# The middleware passes the raw Host header. RFC 7230 makes it
53-
# case-insensitive and lets the client include ``:port``; the
54-
# Protocol docstring is explicit that implementations strip the
55-
# port suffix as needed. Normalize before the cache lookup AND
56-
# the DB query so ``acme.localhost:3001`` resolves the same
57-
# row as the seeded ``acme.localhost``.
58-
host = host.strip().lower().split(":", 1)[0]
55+
# The middleware passes the raw Host header. Use the SDK's shared
56+
# normalizer rather than hand-rolling a lower-case + port split:
57+
# a naive ``split(":", 1)`` mangles bracketed IPv6 authorities,
58+
# and rolling your own guarantees it drifts from the key the rest
59+
# of the SDK uses. Normalize before the cache lookup AND the DB
60+
# query so ``acme.localhost:3001`` resolves the same row as the
61+
# seeded ``acme.localhost``. Note the ``tenants.host`` column must
62+
# be seeded in this same form (IPv6 de-bracketed, e.g. ``::1``).
63+
host = normalize_host_key(host)
5964
# Bounded FIFO cache — when full, the oldest insertion is
6065
# evicted regardless of access frequency. Fine for stable
6166
# tenant sets under ``cache_size``; adopters with churn or

src/adcp/server/tenant_registry.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
from collections.abc import Awaitable, Callable
3636
from dataclasses import dataclass
3737
from typing import TYPE_CHECKING, Any, Literal
38-
from urllib.parse import urlparse
38+
39+
from adcp.server.tenant_router import normalize_host_key
3940

4041
if TYPE_CHECKING:
4142
from adcp.decisioning.accounts import AccountStore
@@ -226,24 +227,26 @@ def _get_lock(self, tenant_id: str) -> asyncio.Lock:
226227

227228
@staticmethod
228229
def _normalize_host(raw: str) -> str:
229-
"""Lower-case and strip any port suffix from a host or URL.
230+
"""Reduce a host or URL to its tenant-lookup key.
230231
231232
Accepts both full URLs (``https://acme.example.com``) and raw
232233
Host-header values (``acme.example.com``, ``acme.example.com:443``).
234+
Delegates to :func:`~adcp.server.tenant_router.normalize_host_key`
235+
so that a tenant registered by ``agent_url`` is reachable by the
236+
``Host`` header the subdomain router resolves — the two used to
237+
key the same address differently.
238+
239+
Beyond lower-casing and port stripping this discards any
240+
``user:pw@`` userinfo, removes IPv6 brackets (``[::1]:8443`` is
241+
keyed as ``::1``), and folds a trailing FQDN-root dot.
233242
234243
Note: port stripping is correct for ``Host`` headers where the port
235244
matches the scheme default. Some load-balancers forward
236245
``X-Forwarded-Host`` with non-default ports preserved; callers
237246
using that header should strip the port themselves before passing
238247
the value to :meth:`resolve_by_host` or :meth:`resolve`.
239248
"""
240-
if "://" in raw:
241-
host = urlparse(raw).netloc or raw
242-
else:
243-
host = raw
244-
if ":" in host:
245-
host = host.rsplit(":", 1)[0]
246-
return host.lower()
249+
return normalize_host_key(raw)
247250

248251
async def _run_validator(self, tenant_id: str) -> bool:
249252
"""Invoke the configured validator; return True when valid."""

src/adcp/server/tenant_router.py

Lines changed: 111 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,13 @@ def build_context(meta):
9090

9191
import contextvars
9292
import inspect
93+
import ipaddress
9394
import time
9495
from collections import OrderedDict
9596
from collections.abc import Awaitable, Callable
9697
from dataclasses import dataclass, field
9798
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
99+
from urllib.parse import urlsplit
98100

99101
if TYPE_CHECKING:
100102
from collections.abc import Mapping
@@ -141,26 +143,32 @@ class SubdomainTenantRouter(Protocol):
141143
async def resolve(self, host: str) -> Tenant | None:
142144
"""Return the :class:`Tenant` for ``host`` or ``None`` to 404.
143145
144-
``host`` is the raw ``Host`` header value (lower-cased by
145-
the middleware before this call). Implementations strip any
146-
``:port`` suffix as needed; the middleware doesn't.
146+
``host`` is the raw ``Host`` header value; the middleware does
147+
not normalize it. The bundled implementations run it through
148+
:func:`normalize_host_key`, and custom implementations should
149+
do the same rather than hand-rolling a port strip — see that
150+
function for the cases a naive split gets wrong.
147151
"""
148152
...
149153

150154

151155
class InMemorySubdomainTenantRouter:
152156
"""Reference :class:`SubdomainTenantRouter` for dev / test.
153157
154-
Backed by a static ``host → Tenant`` dict. Lookup is exact
155-
match on the lower-cased host (with the port suffix stripped).
156-
Production adopters swap to a SQL-backed impl that hits their
157-
tenant table.
158+
Backed by a static ``host → Tenant`` dict. Lookup is an exact match
159+
on the :func:`normalize_host_key` form of the host. Production
160+
adopters swap to a SQL-backed impl that hits their tenant table.
161+
162+
Note that IPv6 keys are stored de-bracketed and compressed, so
163+
``{"[::1]": ...}`` is registered under ``::1``.
158164
"""
159165

160166
def __init__(self, tenants: Mapping[str, Tenant]) -> None:
161-
# Normalize keys to lower-cased + port-stripped at construction
162-
# so resolve() can be a single dict lookup. Adopters who pass
163-
# mixed case (``Acme.Example.com``) get the obvious behavior.
167+
# Normalize keys at construction so resolve() is a single dict
168+
# lookup. Adopters who pass mixed case (``Acme.Example.com``) or
169+
# a bracketed IPv6 literal get the obvious behavior. The helper
170+
# is idempotent, so normalizing keys here and hosts in resolve()
171+
# cannot disagree.
164172
self._tenants: dict[str, Tenant] = {
165173
_normalize_host(host): tenant for host, tenant in tenants.items()
166174
}
@@ -171,9 +179,9 @@ async def resolve(self, host: str) -> Tenant | None:
171179

172180
# Type alias for adopter-supplied lookup callables. Either sync (returns
173181
# Tenant | None) or async (returns Awaitable[Tenant | None]) is accepted —
174-
# CallableSubdomainTenantRouter awaits at call time. Receives the
175-
# already-normalized (lower-cased + port-stripped) host so adopters don't
176-
# reimplement the parser.
182+
# CallableSubdomainTenantRouter awaits at call time. Receives the host
183+
# already run through normalize_host_key() so adopters don't reimplement
184+
# the parser.
177185
TenantResolver = Callable[[str], "Tenant | None | Awaitable[Tenant | None]"]
178186

179187

@@ -182,9 +190,11 @@ class CallableSubdomainTenantRouter:
182190
183191
The adopter passes a single callable mapping a normalized host to a
184192
:class:`Tenant` (or ``None`` for 404). The framework owns host
185-
normalization (lower-case + port-strip), so adopters write only the
186-
lookup itself — typically a single SQL query against their tenant
187-
table.
193+
normalization (see :func:`normalize_host_key`), so adopters write
194+
only the lookup itself — typically a single SQL query against their
195+
tenant table. Adopter lookup tables must be keyed in that same form:
196+
notably, IPv6 hosts arrive de-bracketed and compressed (``::1``, not
197+
``[::1]``).
188198
189199
The callable may be sync or async; the router awaits at call time.
190200
@@ -256,9 +266,10 @@ def __init__(
256266
"""Construct the router.
257267
258268
:param resolver: Callable taking a normalized host string and
259-
returning ``Tenant | None`` (sync or async). Receives
260-
already-normalized hosts — lower-cased with any
261-
``:port`` suffix stripped.
269+
returning ``Tenant | None`` (sync or async). Receives hosts
270+
already run through :func:`normalize_host_key` — lower-cased
271+
and IDNA-folded, with userinfo, the ``:port`` suffix, IPv6
272+
brackets and any trailing root dot removed.
262273
:param cache_size: Maximum number of cached lookups. ``0``
263274
disables caching entirely (the adopter callable is awaited
264275
on every request). Must be ``>= 0``.
@@ -442,18 +453,88 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
442453
# ----- helpers -----------------------------------------------------------
443454

444455

445-
def _normalize_host(host: str) -> str:
446-
"""Lower-case and strip ``:port`` suffix.
447-
448-
The ``Host`` header is case-insensitive per RFC 7230, but a
449-
case-sensitive dict lookup would miss legitimate variations.
450-
Also strips the port suffix so ``acme.example.com:443`` resolves
451-
the same as ``acme.example.com``.
456+
def normalize_host_key(value: str) -> str:
457+
"""Return the canonical tenant-lookup key for a host or URL.
458+
459+
This is the single normalizer shared by every host-keyed lookup in
460+
the SDK (:class:`InMemorySubdomainTenantRouter`,
461+
:class:`CallableSubdomainTenantRouter`,
462+
:class:`~adcp.server.tenant_registry.TenantRegistry`, and the
463+
reference-seller example). Keeping one implementation is what makes
464+
a registration key and a request-time ``Host`` header agree.
465+
466+
Accepts full URLs (``https://acme.example.com:8443/agent``) and raw
467+
``Host`` header values (``acme.example.com``, ``[::1]:8080``), and:
468+
469+
* discards any ``user:pw@`` userinfo,
470+
* strips the ``:port`` suffix,
471+
* removes IPv6 brackets and compresses the address
472+
(``[2001:DB8::0:1]:443`` → ``2001:db8::1``),
473+
* folds a single trailing FQDN-root dot,
474+
* lower-cases and applies IDNA-2008 folding, so a tenant registered
475+
under either the U-label or the A-label is reachable by both.
476+
477+
**Never raises.** The ``Host`` header is attacker-controlled and is
478+
normalized before any tenant exists to reject the request, so a
479+
raise here would turn a 404 into a 500. Input this function cannot
480+
parse yields a best-effort key that simply fails to match, and the
481+
caller 404s as it would for any unknown host.
452482
"""
453-
normalized = host.strip().lower()
454-
if ":" in normalized:
455-
normalized = normalized.split(":", 1)[0]
456-
return normalized
483+
raw = value.strip()
484+
485+
# Bare/bracketed IP-literal short-circuit. Without it, urlsplit reads
486+
# an unbracketed "2001:db8::1" as host:port and yields '2001', which
487+
# would also make this function non-idempotent over its own output —
488+
# load-bearing because InMemorySubdomainTenantRouter normalizes
489+
# registration keys and then normalizes the lookup host again.
490+
candidate = raw[1:-1] if raw.startswith("[") and raw.endswith("]") else raw
491+
try:
492+
return str(ipaddress.ip_address(candidate))
493+
except ValueError:
494+
pass
495+
496+
try:
497+
parts = urlsplit(raw if "://" in raw else "//" + raw)
498+
# .hostname de-brackets IPv6, drops userinfo and port, lower-cases.
499+
host = parts.hostname
500+
except ValueError:
501+
host = None
502+
if not host:
503+
host = raw.lower() # unparseable authority -> best-effort key
504+
if host.endswith("."):
505+
host = host[:-1] # single FQDN-root dot, matching canonicalize_host
506+
507+
if host.isascii():
508+
# ASCII fast path, and it is not a micro-optimization. For all-ASCII
509+
# input `canonicalize_host` either returns exactly this value or
510+
# raises -- and every raise is caught below and falls back to exactly
511+
# this value. So the answer is identical, while the slow path is
512+
# skipped for the hosts every real deployment actually uses.
513+
#
514+
# What that buys: `canonicalize_host` lives in `adcp.signing`, whose
515+
# package import pulls 30 modules (~0.2s locally, more on a cold CI
516+
# runner). Reaching it at module level slowed EVERY `import
517+
# adcp.server`; reaching it here on the ASCII path moved that cost
518+
# into tenant-router construction, which is enough to blow the
519+
# storyboard runner's 30s readiness budget on the one example that
520+
# builds a router. Now it is only paid for a genuinely non-ASCII host.
521+
return host
522+
523+
# Deferred: only a non-ASCII host needs UTS-46, and only then is the
524+
# `adcp.signing` import worth its cost.
525+
from adcp.signing._idna_canonicalize import canonicalize_host
526+
527+
try:
528+
return canonicalize_host(host)
529+
except (UnicodeError, ValueError):
530+
# idna.IDNAError subclasses UnicodeError, so this covers every
531+
# documented raise (underscore labels, over-long labels, '').
532+
return host
533+
534+
535+
def _normalize_host(host: str) -> str:
536+
"""Deprecated alias for :func:`normalize_host_key`."""
537+
return normalize_host_key(host)
457538

458539

459540
def _extract_host_header(scope: Scope) -> str | None:

tests/test_subdomain_tenant_router.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
Tenant,
3636
current_tenant,
3737
)
38+
from adcp.server.tenant_router import normalize_host_key # noqa: E402
3839

3940
# ----- handler that surfaces the resolved tenant ------------------------
4041

@@ -110,6 +111,50 @@ def test_in_memory_router_strips_port_suffix() -> None:
110111
assert result.id == "acme"
111112

112113

114+
def test_in_memory_router_resolves_ipv6_literal_host() -> None:
115+
"""A bracketed IPv6 Host header resolves to its own tenant only.
116+
117+
The old first-colon split collapsed any host whose first colon
118+
follows ``[`` to the key ``'['``, so an unrelated IPv6 literal
119+
matched the loopback tenant instead of 404ing.
120+
"""
121+
router = InMemorySubdomainTenantRouter(
122+
tenants={"[::1]": Tenant(id="loopback", display_name="Loopback")}
123+
)
124+
result = asyncio.run(router.resolve("[::1]:8080"))
125+
assert result is not None
126+
assert result.id == "loopback"
127+
128+
# Different address, no tenant registered for it -> must 404.
129+
assert asyncio.run(router.resolve("[::2]")) is None
130+
assert asyncio.run(router.resolve("[2001:db8::1]")) is None
131+
132+
133+
def test_in_memory_router_distinct_ipv6_tenants_do_not_collide() -> None:
134+
"""Two IPv6 tenants must occupy two registration keys, not one.
135+
136+
Registration keys are normalized at construction, so a normalizer
137+
that truncates at the first colon merges every IPv6 tenant into a
138+
single dict slot — last write wins and one tenant's host resolves
139+
to the other tenant.
140+
"""
141+
router = InMemorySubdomainTenantRouter(
142+
tenants={
143+
"[::1]": Tenant(id="loopback", display_name="Loopback"),
144+
"[::2]": Tenant(id="other", display_name="Other"),
145+
}
146+
)
147+
assert len(router._tenants) == 2
148+
149+
loopback = asyncio.run(router.resolve("[::1]"))
150+
assert loopback is not None
151+
assert loopback.id == "loopback"
152+
153+
other = asyncio.run(router.resolve("[::2]"))
154+
assert other is not None
155+
assert other.id == "other"
156+
157+
113158
def test_in_memory_router_satisfies_protocol() -> None:
114159
router = InMemorySubdomainTenantRouter(tenants={})
115160
assert isinstance(router, SubdomainTenantRouter)
@@ -496,6 +541,39 @@ async def noop_send(_message):
496541
assert sentinel == ["websocket", "lifespan"]
497542

498543

544+
# ----- normalize_host_key ---------------------------------------------
545+
546+
547+
def test_normalize_host_key_never_raises_on_hostile_input() -> None:
548+
"""The lookup-key helper must fail soft on any Host header value.
549+
550+
The Host header is attacker-controlled and reaches this helper
551+
before any tenant is resolved. A raise here would turn today's
552+
404 into a 500, so every hostile shape must return a string.
553+
Deliberately no try/except: a raise fails the test with the
554+
exception itself.
555+
"""
556+
hostile = [
557+
"under_score.example.com",
558+
"a" * 100 + ".example.com",
559+
"",
560+
" ",
561+
"[::1",
562+
"]::1[",
563+
"acme.example.com:abc",
564+
"%00.example.com",
565+
"http://",
566+
"@",
567+
]
568+
for value in hostile:
569+
assert isinstance(normalize_host_key(value), str)
570+
571+
572+
def test_normalize_host_key_folds_trailing_root_dot() -> None:
573+
"""``acme.example.com.`` and ``acme.example.com`` are one tenant."""
574+
assert normalize_host_key("acme.example.com.") == "acme.example.com"
575+
576+
499577
# ----- helpers --------------------------------------------------------
500578

501579

0 commit comments

Comments
 (0)