Skip to content

Three divergent Host-header normalizers: all mangle IPv6 authorities, one leaks userinfo into the tenant key #990

Description

@KonstantinMirin

Summary

Three separate implementations of "normalize a Host header for tenant lookup" exist in the tree. They disagree with each other, all three mangle IPv6 authorities, and one leaks userinfo into the lookup key. One of the three is in examples/, which is where adopters copy from.

The three copies

location body
A src/adcp/server/tenant_router.py:443-454 (_normalize_host) host.strip().lower(), then split(":", 1)[0]
B src/adcp/server/tenant_registry.py:227-246 (_normalize_host) urlparse(raw).netloc or raw, then rsplit(":", 1)[0], then .lower()
C examples/v3_reference_seller/src/tenant_router.py:58 (inlined in resolve()) host.strip().lower().split(":", 1)[0]

Reproduce

from adcp.server.tenant_router import _normalize_host as A
from adcp.server.tenant_registry import TenantRegistry
B = TenantRegistry._normalize_host
def C(h): return h.strip().lower().split(":", 1)[0]
input A (router) B (registry) C (example)
[::1]:8080 '[' '[::1]' '['
[::1] '[' '[:' '['
[2001:db8::1]:443 '[2001' '[2001:db8::1]' '[2001'
ACME.example.com:443 'acme.example.com' 'acme.example.com' 'acme.example.com'
https://user:pw@acme.example.com 'https' 'user' 'https'
acme.example.com. 'acme.example.com.' 'acme.example.com.' 'acme.example.com.'

Three defects visible in that table:

  1. IPv6 authorities are destroyed. A and C split on the first colon, so any bracketed IPv6 Host collapses to '[' or '[2001'. B splits on the last colon and so keeps the brackets it should have stripped ('[::1]') — and for a portless [::1] produces '[:'.
  2. B accepts userinfo into the key. It uses .netloc, not .hostname, so https://user:pw@acme.example.com normalizes to 'user'. B's docstring says it accepts full URLs, so this is a reachable input for resolve_by_host / resolve callers who pass an agent_url.
  3. No trailing-dot handling anywhere. acme.example.com. and acme.example.com are different tenants in all three.

Row 5 for A and C ('https') is an artifact of feeding them a URL, which their contract does not promise to accept — not a defect on its own, but it shows how far apart the three contracts have drifted while all three are named the same thing.

Impact

Fail-closed in all three cases: a mangled key matches no tenant row, and the request 404s. There is no cross-tenant resolution here — no realistic tenant is registered under '[' or 'user'. So this is a correctness and consistency defect, not an isolation break.

What it costs in practice:

  • A deployment reached over an IPv6 literal (health checkers, direct-IP probes, dual-stack ingress that forwards the literal) 404s on every request with no diagnostic pointing at host parsing.
  • An adopter who registers a tenant by agent_url through TenantRegistry and happens to have userinfo in that URL registers it under a garbage key.
  • Copy C is in the reference seller example, so the bug propagates into adopter code by design.

Related hazard, worth deciding before fixing

The obvious fix — route all three through adcp.signing._idna_canonicalize.canonicalize_host, which strips brackets, lowercases, strips one trailing dot and A-label-encodes — is not a drop-in here. The Host header is attacker-controlled, and canonicalize_host raises idna.IDNAError on underscore hosts and over-long labels. An unwrapped call turns today's clean 404 into an unhandled exception on the ASGI path, i.e. trades a correctness bug for a reachable 500. Whatever the fix is, it needs an explicit except that degrades to "no tenant" rather than propagating.

urlsplit(f"//{host}").hostname is the cheaper option if the IDNA half is not wanted: it de-brackets IPv6, lowercases, strips the port correctly, and drops userinfo, without introducing a raising path.

Suggested direction

One shared helper, used by all three sites, with an explicit non-raising contract (returns the best-effort key, never raises). Update the example in the same change so it stops being a divergent copy — otherwise the next adopter reintroduces C.

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