Component: primitives/webhook_bus.py — _PinnedResolution (pin map built at ~L184, _pinned at ~L199), _vetted_addrinfo (host = parsed.hostname, ~L144), and the redirect guard redirect_request that calls pin.add(host, vetted) on each hop (~L269).
The defect.
The outbound-webhook SSRF guard resolves a host once, vets that every address is public, then pins socket.getaddrinfo to exactly those IPs for the send so the connect cannot re-resolve to a private address (DNS rebind). f09503 extends the pin to redirect hops. But the pin is keyed on the LOWERCASED host and matched against the ORIGINAL-case host urllib actually resolves:
- pin key:
host = parsed.hostname(_vetted_addrinfo).urllib.parse.urlsplit().hostnameLOWERCASES the host, and the pin is storedself._pins = {host: vetted}/ extendedself._pins[host] = vettedunder that lowercased key. - pin lookup:
_pinned(h, p, …): vetted = pins.get(h); if vetted is not None: return <pinned IPs>; return orig(h, p, *a, **k). Herehis the host urllib handsgetaddrinfo— taken VERBATIM from the request/redirect URL, original case.
So any host string containing an uppercase letter misses the pin: pins.get("MixedCase.example.test") against key "mixedcase.example.test" returns None -> return orig(h, …) = the real system resolver, unpinned. The connect then re-resolves the name a second time — exactly the resolve-then-discard TOCTOU the pin exists to prevent.
The redirect leg is where an attacker controls the host string. The Location header value is fully attacker-chosen; inserting one uppercase letter into an otherwise-normal host guarantees the pin miss while _vetted_addrinfo still lowercases it into the pin key. _vetted_addrinfo resolves the hop to a public IP (guard passes), pins it under mixedcase.example.test, then urllib connects resolving MixedCase.example.test via the real resolver -> a rebinding DNS server hands back 169.254.169.254 / an RFC1918 address for the actual connect.
Reproduction (mechanism; standalone harness). Shows the pin miss on an uppercase host.
import socket
from primitives import webhook_bus as WB
pin = WB._PinnedResolution("host.example.test", [(socket.AF_INET, "203.0.113.10")])
# redirect guard vets + pins the hop under the LOWERCASED host:
pin.add("mixedcase.example.test", [(socket.AF_INET, "203.0.113.11")])
with pin: # installs _pinned as socket.getaddrinfo
# urllib connects resolving the ORIGINAL-case host from the Location header:
res = socket.getaddrinfo("MixedCase.example.test", 443)
# res came from the REAL resolver (pin missed) — not the vetted [203.0.113.11].
print("pinned IPs bypassed — real resolver used for MixedCase.example.test")
Expected output: the lookup for MixedCase.example.test falls through to the real resolver instead of returning the pinned 203.0.113.11; on a live send backed by rebinding DNS, the connect lands on the rebind target.
Expected (correct) behavior: the pin must cover the host regardless of case — a redirect to MixedCase.example.test connects only to the IPs vetted for it, or is refused.
Scope. live=True webhook to an operator-approved public endpoint that returns 301/302/303 with a Location host containing any uppercase letter, backed by attacker-controlled rebinding DNS. (The initial leg is also weakened when the operator's own hook_url host has uppercase, but the delta's new exposure is the attacker-chosen redirect host.)
Fix. Casefold the host on both sides — when building/extending the pin (host = parsed.hostname.lower()) and inside _pinned (vetted = pins.get(h.lower())). Also strip a trailing dot so host. and host share one key.
Classification: CWE-367 → CWE-918