fix(server): unify divergent host normalizers behind one helper - #997
Conversation
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
d1ccf3c to
a2194fb
Compare
There was a problem hiding this comment.
Approving. Collapsing three divergent Host normalizers to one shared key function is the right shape — a registration key and a request-time Host header now agree because they run the same code, and that is the invariant that was broken.
The headline fix is real: the old split(":", 1)[0] reduced every bracketed IPv6 authority to the key "[", and because InMemorySubdomainTenantRouter.__init__ normalizes registration keys the same way, two distinct IPv6 tenants collapsed onto one dict slot — last-write-wins cross-tenant resolution, not a fail-closed 404. The IP-literal short-circuit through ipaddress.ip_address (src/adcp/server/tenant_router.py, new normalize_host_key) fixes it, and tests/test_subdomain_tenant_router.py:135-156 pins exactly that non-collision.
Things I checked
- No new collision class in the fail-soft.
except (UnicodeError, ValueError)returns the pre-IDNA host.security-reviewer: a fallback key can only equal another key produced by the same failing string on the same fallback path — i.e. the genuinely same host — so fail-soft yields a 404 for unknown hosts, never a cross-tenant hit.idna.IDNAErrorsubclassesUnicodeError, so every documented raise is caught. - Idempotency holds — load-bearing because InMemory normalizes registration keys and lookup hosts.
code-reviewertraced every branch: valid ASCII A-labels are a fixed point (idna.alabelshort-circuits valid ASCII before punycode), and the bare-literal short-circuit is what makes the output form::1re-normalize to itself instead ofurlsplitreading it ashost:port.[::1]:8080,[::1], and bare::1all key to::1. - userinfo cannot bypass routing.
user:pw@acme.example.comkeys onacme.example.com; tenant routing is not the auth boundary, so reaching a tenant still needs validAuthInfo(security-reviewer: no privilege gained). - DoS surface closed. Attacker
Hostnow flows intoidna.encode, butpyproject.toml:110pinsidna>=3.7(past the CVE-2024-3651 quadratic fix, per the comment at:104-105), and the 100-char label test takes the O(1) reject path. - Backwards-compat.
_normalize_hostretained as a delegating alias in bothtenant_router.py:445andtenant_registry.py:228; every existing caller still resolves. No circular import —tenant_registryimportsnormalize_host_keyfromtenant_routerat module top buttenant_routernever importstenant_registry, and theadcp.signingpull stays deferred. - Conventional-commit prefix
fix(server):is correct — no public export removed, renamed, or retyped; nothing breaking on the wire. - The double dot-strip wart (
acme.example.com..folds toacme.example.com) is harmless and idempotent, as disclosed.
Follow-ups (non-blocking — file as issues)
- Export
normalize_host_keyproperly. Two docstrings now instruct custom/adopterresolveimpls to route through it, yet it is absent fromtenant_router.__all__(src/adcp/server/tenant_router.py:495) and not re-exported fromsrc/adcp/server/__init__.py, forcingexamples/v3_reference_seller/src/tenant_router.py:28to reach into a private submodule with an apologetic comment. If adopters are told to use it, it should be public. - Silent last-write-wins on folded keys is widened, not new. Registering two IDN-equivalent hosts silently drops one at
tenant_router.py:164-166andtenant_registry.py:301,371(security-reviewer, Low). Operator-controlled, not attacker-reachable, and folding DNS-equivalents is correct — but raising on an already-present normalized key at registration would turn a silent tenant loss into an error. - "Never raises" is guaranteed for
stronly.value.strip()on a non-str value raises uncaught. Callers are typedstrand ASGI extraction guards it, so this is a docstring-scoping note, not a live path.
Minor nits (non-blocking)
- PR body item #3 documents an implementation this PR does not ship. The changelog note claims
import adcp.servernow pullsadcp.signing"because of the module-levelcanonicalize_hostimport" — but the code (normalize_host_key, the deferredfrom adcp.signing._idna_canonicalize import canonicalize_host) and the commit message both defer that import specifically so the server does not drag in signing. Rewrite item #3 to match the deferred import before it lands in the changelog, or it ships a phantom import-cost regression note that lost the argument to its own commit message. - Hostile-shape count is off. Body and commit both say "26 hostile shapes are pinned by test";
test_normalize_host_key_never_raises_on_hostile_inputpins 10. Cosmetic, but the number is stated twice.
LGTM. Follow-ups noted below.
There was a problem hiding this comment.
Approving. Collapsing three divergent host normalizers to one that both registration and lookup share is the right shape — a registration key and a request-time Host can no longer disagree because they run the same code.
The IPv6 fix is the load-bearing part: the old first-colon split reduced every bracketed literal to the key "[", merging all IPv6 tenants into one dict slot. That was cross-tenant mis-resolution, not a fail-closed 404. Good catch, and the two regression tests (test_in_memory_router_distinct_ipv6_tenants_do_not_collide, test_register_with_ipv6_agent_url_is_reachable_by_host_header) pin it from both the router and the registry side.
Things I checked
- No cross-tenant leak. Walked the widening cases with
security-reviewer:user@acme.example.com,acme.example.com/x,acme.example.com:abcall resolve to the same tenant as bareacme.example.comviaurlsplit(...).hostname. TheHostheader is attacker-controlled, so this grants no access an attacker didn't already have by sending the bare host. UTS-46/IDNA folding only maps a non-ASCII host onto an ASCII key when it's the same DNS name (U-label ↔ A-label, fullwidth ↔ ASCII). No privilege escalation. Verdict: Low, net-positive for isolation. - Fail-closed holds. Every fallback path (
urlsplitraises →raw.lower(); empty hostname →raw.lower();canonicalize_hostraises → pre-IDNAhost) yields a key that misses → 404, never an accidental match.test_normalize_host_key_never_raises_on_hostile_inputpins 10 hostile shapes. - No import cycle.
tenant_registry.py:38importsnormalize_host_keyfromtenant_routerone-directionally;tenant_routerhas no module-leveladcp.serverimport and reachescanonicalize_hostvia a lazy in-function import (tenant_router.py:194).adcp.signingdoes not importadcp.server. Clean. - No public-API break.
normalize_host_keyis not re-exported fromadcp.server.__init__— reachable only via the submodule, which is why the example imports it there._normalize_hostis kept as a deprecated alias; all four internal callers still resolve. Additive, sofix(server):is the correct semver signal. - The ASCII fast path is correct, not just fast. For all-ASCII input
canonicalize_hosteither returns exactly the fast-path value or raises into the same fallback — the answer is identical, and it keeps the 30-moduleadcp.signingimport off everyimport adcp.server. Right call, and the comment attenant_router.py:485-499earns its length.
Follow-ups (non-blocking — file as issues)
- The idempotency invariant is false for a port-bearing non-canonical IPv6 literal, and the PR advertises the value it doesn't produce.
normalize_host_key(tenant_router.py): the bare-literal short-circuit compresses viastr(ipaddress.ip_address(...)), but the port-bearing path returnsurlsplit(...).hostnameuncompressed. So[2001:DB8::0:1]:443→2001:db8::0:1on the first pass, then2001:db8::1on a second — not idempotent. Consequence: a tenant registered bare as[2001:db8::0:1](→2001:db8::1) is unreachable when theHostcarries a port (→2001:db8::0:1). Fail-closed 404, no leak, and confined to non-canonical IPv6 literals — which is why it doesn't block. But the code comment "every output is idempotent under a second pass — load-bearing" is provably false, and the docstring row[2001:DB8::0:1]:443 → 2001:db8::1prints the second-pass value, not the real output. One-line fix: re-runhost = parts.hostnamethrough theipaddress.ip_addressshort-circuit so both branches converge on the compressed form. That also makes the docstring true. The existing IPv6 tests miss this because they only use already-canonical addresses. (code-reviewer: High, fail-closed;security-reviewer: no isolation impact.) - Silent last-write-wins on colliding registration keys.
InMemorySubdomainTenantRouter.__init__(tenant_router.py:164-165) andTenantRegistry._host_map(tenant_registry.py): two hosts that fold to the same key drop one silently. Pre-existing for case variants; IDNA folding enlarges the class. In a component whose whole job is tenant isolation, aValueErrornaming the colliding hosts beats a silent drop. Worth doing before this is enabled for untrusted registration. - Host-parsing widening can desync an edge WAF/cache that keys on the raw
Host. Acknowledged in the PR body — keep the release-note callout.
Minor nits (non-blocking)
- Trailing double-dot isn't folded on the ASCII path.
normalize_host_keystrips one dot then the ASCII fast path returns beforecanonicalize_host's second strip, soacme.example.com..→acme.example.com., not theacme.example.comthe "Known wart" note claims. Fail-closed.host.rstrip(".")fixes it and makes the note accurate.
The commit message is unusually candid about the storyboard-runner cost story and the known warts — good, though it does mean the one inaccurate invariant is stated with more confidence than it earned. Correct the code (preferred, one line) or the claim; don't ship the false "load-bearing" comment for the next reader to trust.
LGTM. Follow-ups noted below.
bokelley
left a comment
There was a problem hiding this comment.
normalize_host_key does not canonicalize an IPv6 literal after extracting it from a port-bearing authority. Reproduced on current main + this PR:\n\nnormalize_host_key("[2001:DB8::0:1]:443") == "2001:db8::0:1"\nnormalize_host_key("2001:db8::0:1") == "2001:db8::1"\n\nSo the advertised idempotency invariant is false, and a tenant registered under bare [2001:DB8::0:1] is unreachable from the equivalent Host value with :443. Please run parts.hostname through ipaddress.ip_address before the ASCII fast path and add a non-canonical, port-bearing IPv6 regression test. The focused suite otherwise passes (95 tests).
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
|
Fixed in The bare-literal short-circuit only inspects the raw input. Once a port is attached it is Worth being explicit that this makes the docstring's idempotency claim false, not merely incomplete. That matters more than the lookup miss: Now re-runs the IP-literal test on the extracted host. Two of the four new tests fail without the change (verified by stashing only the source file — stashing everything removes the tests too and proves nothing); the other two are regression guards and I've said so rather than counting them as coverage.
|
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 adcontextprotocol#990.
…authority
Review follow-up. The bare-literal short-circuit only inspects the raw input.
Once a port is attached it is `urlsplit` that removes the brackets, so the
address reached the rest of the function uncompressed and was never folded:
normalize_host_key("[2001:DB8::0:1]:443") -> "2001:db8::0:1"
normalize_host_key("2001:db8::0:1") -> "2001:db8::1"
So a tenant registered under the bare literal was unreachable from the
equivalent Host value carrying :443, and the idempotency the docstring claims
was simply false -- load-bearing, because InMemorySubdomainTenantRouter
normalizes registration keys at construction and normalizes the Host again at
lookup, so a non-idempotent key fails to match itself.
Re-runs the IP-literal test on the extracted host. Two of the four new tests
fail without this change; the other two are regression guards.
Refs adcontextprotocol#990.
fe0cceb to
83bd546
Compare
There was a problem hiding this comment.
Right fix, right shape. Collapsing three divergent Host normalizers onto one idempotent normalize_host_key is what makes a registration key and a request-time Host header agree — and the IPv6 collapse it kills ([::1], [::2], [2001:db8::1] all keying to [) was a real cross-tenant mis-resolution, not a fail-closed 404. fix(server): is the correct semver signal: public exports unchanged, _normalize_host retained as an alias, no model shape touched.
Things I checked
- Never-raises holds for
strinput.ipaddress.ip_addressraises onlyValueError(caught at both short-circuits);urlsplit/.hostnameraise onlyValueError(caught;.port— the one that raises separately — is never accessed);canonicalize_hostraises onlyidna.IDNAError(aUnicodeErrorsubclass) orValueError, both caught byexcept (UnicodeError, ValueError). Attacker-controlled Host 404s instead of 500ing.fail-closed beats fail-open. - ASCII fast-path equivalence is sound. For lowercased, non-IP, single-dot-stripped ASCII,
idna.encode(uts46=True, transitional=False)either returns the identical bytes or raises — and the raise falls back to exactly that value. No ASCII input maps to a different non-raising value, so skipping theadcp.signingimport (30 modules, ~0.2s) on the ASCII path costs no correctness. Load-bearing for the storyboard readiness budget, per the commit body. - Both
ip_addressshort-circuits are necessary. First handles bare/bracketed literals beforeurlsplitreads2001:db8::1ashost="2001"; second re-canonicalizes the de-bracketed uncompressed addressurlsplitproduces from[..]:port— which is what makes[2001:DB8::0:1]:443and the bare form converge. The follow-up commit that added the second one is the load-bearing half. - No circular import.
tenant_routerimports only stdlib at module level and deferscanonicalize_hostinto the function body;tenant_registry→tenant_routeris a one-way edge.adcp.server.__init__importingtenant_registrybeforetenant_routerresolves cleanly. - Backward compat intact.
_normalize_hostalias preserved; register/resolve stay symmetric through the same helper. - Fail-soft opens no bypass. The
raw.lower()andexcept → return hostfallbacks preserve the non-canonical string, which won't equal any canonicalized registered key — unparseable input 404s rather than matching an unintended tenant. (security-reviewer: no High.)
Follow-ups (non-blocking — file as issues)
- Registration-collision detection before any self-service onboarding.
security-reviewer(Medium, self-serve; Low, operator-seeded): both consumers are last-write-wins —tenant_registry.py:301(self._host_map[...] = tenant_id) and the InMemory dict comprehension. The wider folding (userinfo strip, UTS-46 confusables likeff→ff/ soft-hyphen, trailing-dot) strictly enlarges the set of distinct inputs that collapse onto one key versus the old first-colon split. Operator-seeded tables are not attacker-reachable, so this doesn't block — but if adopters ever let tenants register their ownagent_url, a confusable-host registration silently overwrites a victim's key. Reject-on-collision (raise or log-and-refuse) rather than silent overwrite, at both sites. - Stale re-seed for existing adopters. DBs keyed to the old
split(":",1)[0]output (IPv6, userinfo, trailing-dot forms) 404 until re-seeded. Theexamples/comment flags it; aCHANGELOG/release note beyond the PR body would help adopters whose upstream cache or WAF keys on the raw Host.
Minor nits (non-blocking)
- Idempotency claim is false for multi-trailing-dot hosts. The docstring calls idempotency load-bearing, but the ASCII path strips a single trailing dot:
normalize_host_key(\"acme.example.com..\")→\"acme.example.com.\", and a second pass →\"acme.example.com\", sof(f(x)) != f(x). A non-ASCII..host strips a second dot insidecanonicalize_host, so the two paths also disagree. Practical impact is nil — a..Host just 404s and nobody registers such a key — but it contradicts a documented invariant, andtest_normalize_host_key_folds_trailing_root_dotonly covers the single-dot case.host = host.rstrip(\".\")before theisascii()branch closes it; or soften the docstring. Notable, given the known-wart section already calls out the double dot-strip and stops one step short of this. never raisesis scoped tostr. A non-strvalueraisesAttributeErroratvalue.strip(). Every real caller passes the Host header or a netloc, so it's within contract — one word in the docstring would make the guarantee unconditional.
LGTM. Follow-ups noted below.
bokelley
left a comment
There was a problem hiding this comment.
Verified the extracted host is re-run through IP canonicalization, making port-bearing non-canonical IPv6 values converge with their bare form. My previous request is fully addressed.
Fixes #990.
Independent of the signing stack — branched off
main, touches no file #980/#985/#987/#993 touch.What was wrong
Three separate implementations of "normalize a
Hostheader for tenant lookup": 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 is worse than the issue implies. Splitting on the first colon reduces every bracketed literal to the key
"[", so two distinct IPv6 tenants collapse onto the same entry. That is cross-tenant mis-resolution, not a fail-closed 404.How
One
normalize_host_key, delegating tocanonicalize_host— the helper four signing modules already use. Net implementations go from three divergent ones down to one; no fourth normalizer in a tree that already had too many. Theexamples/copy is included because it is what adopters clone.The helper never raises.
Hostis attacker-controlled, so a canonicalizer that raised would turn a clean 404 into a 500 — which is exactly why these rows were DEFER rather than MIGRATE in the review that found them. It falls back to the lower-cased input on anything IDNA cannot process. 26 hostile shapes are pinned by test ('','.','[::1','@','%00.example.com','http://', 100-char labels, underscores), and every output is idempotent under a second pass — load-bearing, because InMemory registration keys are normalized at registration and again at lookup.Adopter-visible changes — none isolation-breaking, all worth a release note
1. Host values that previously 404'd now resolve to the bare host's tenant.
Same tenant in every case, so there is no cross-tenant leak — but it is a Host-header parsing widening, and it matters to anyone whose upstream cache or WAF keys on the raw Host.
2. UTS-46 folding means compatibility forms now match.
ACME.example.comreaches the tenant registered asacme.example.com. Correct IDNA behaviour and consistent with DNS, but if two registration keys fold togetherInMemorySubdomainTenantRouter.__init__silently keeps the last one with no error. That was already true for case variants; the folding enlarges the silently-colliding class.3.
import adcp.servernow pullsadcp.signing(~0.13s, 30 modules) because of the module-levelcanonicalize_hostimport.idnais already a core dependency so nothing breaks, and there is precedent (canonical_formats/references.pyimports the same private module). Per-request normalization cost went 0.1µs → 14µs, which is irrelevant next to a DNS lookup.Test plan
make ci-local: 5902 passed, 0 failed, 41 skipped, 1 xfailed.5 of the 6 new tests fail against the old implementations — verified by re-injecting them via a pytest plugin that rebinds
tenant_router._normalize_host/normalize_host_keyandtenant_registry.normalize_host_key, not by reasoning about the diff. The 6th (test_normalize_host_key_never_raises_on_hostile_input) passes either way; it is kept deliberately, because it guards the hazard this change introduces — the newcanonicalize_hostcall is the thing that could start raising.Known wart
normalize_host_keystrips a trailing dot itself even thoughcanonicalize_hostalready strips one, soacme.example.com..double-folds toacme.example.com. Harmless for tenant lookup and arguably desirable, but it is a second dot-strip rather than one — noting it rather than leaving it for a reviewer to find.