Skip to content

Commit 013c06c

Browse files
fix(webhooks): bracket and structurally validate DockerLocalhostRewrite rewrite_to (#996)
* fix(webhooks): bracket and structurally validate DockerLocalhostRewrite rewrite_to `rewrite_to` was interpolated straight into a netloc with no validation. A bare IPv6 value produced an unbracketed authority -- the one shape RFC 3986 makes ambiguous with a port -- so `rewrite_to="::1"` against a URL with a port yielded `https://::1:9000/hook`, which `_canon_authority` mis-splits at the last colon. This is the only place a non-signing layer synthesizes the netloc that later becomes the signed `@authority`. Bare IPv6 literals are now bracketed at construction (`"::1"` -> `"[::1]"`) and folded to canonical compressed form, so the assembled authority is unambiguous and the signed value is stable. The validation is deliberately STRUCTURAL, not a hostname-syntax check. It rejects only what can move the boundary between authority, userinfo, port, path, query or fragment -- path injection, userinfo injection, embedded ports, raw whitespace and control characters, and RFC 6874 IPv6 zone IDs (`%` cannot appear unencoded in a URI authority). It deliberately does NOT enforce hostname syntax. Docker Compose service names legally contain underscores (`my_service`, `host_gateway`) which RFC 952/1123 and IDNA both reject, and Docker's embedded DNS resolves them. Routing `rewrite_to` through the IDNA canonicalizer would have rejected those at construction -- turning a working deployment into a startup crash on upgrade, in the class that exists specifically to serve Docker. Whether a name resolves is the resolver's business; whether it restructures the signed URL is ours. Non-ASCII names are still IDNA-encoded to A-labels, since those cannot go on the wire as-is. Validation lives in `validate_for_sender` rather than `__post_init__` so it also covers direct `apply_hooks` use and hooks not attached to a sender. Fixes #991. * fix(webhooks): reject rewrite_to values that normalize to no host Review follow-up. The guard rejected a structurally unsafe value but still let three shapes through to an empty host, which then assembled to `https://:9000/hook` -- an authority with a port and no host, exactly the shape @target-uri canonicalization rejects, produced by the hook meant to keep the authority well-formed. Each reached the empty host past a different check: - `"[]"` is non-empty until the brackets come off. - `"."` is non-empty until the trailing root dot comes off. - `".."` survived a single-dot strip as `"."`, because the strip used rstrip('.') and ate every dot rather than the one root dot canonicalize_host removes. Rejection is now stated as 'no empty label', which also covers an interior empty label (`"a..b"`). Refs #991.
1 parent 6518c2d commit 013c06c

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

src/adcp/webhook_transport_hooks.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,18 @@
3232

3333
from __future__ import annotations
3434

35+
import ipaddress
3536
from dataclasses import dataclass
3637
from typing import Protocol
3738
from urllib.parse import urlsplit, urlunsplit
3839

40+
#: Characters that, interpolated into a netloc, move the boundary between
41+
#: authority / userinfo / port / path / query / fragment -- i.e. rewrite the
42+
#: signed URL into a different URL. `%` covers RFC 6874 IPv6 zone IDs.
43+
#: `:` is deliberately absent: IPv6 literals are made of them, so a stray port
44+
#: is caught after the IP-literal parse instead.
45+
_STRUCTURAL_CHARS = frozenset('/@?#\\[]%"<>^`{|}')
46+
3947

4048
class TransportHook(Protocol):
4149
"""Rewrite the destination URL before SSRF runs.
@@ -77,10 +85,130 @@ class DockerLocalhostRewrite:
7785
The check happens via :meth:`validate_for_sender`, called by
7886
:meth:`WebhookSender._from_strategy` (and ``__init__``) when
7987
``transport_hooks`` is set.
88+
89+
``rewrite_to`` is validated and canonicalized at construction: it
90+
must be a hostname or IP literal, is lower-cased, and bare IPv6
91+
literals are bracketed automatically (``"::1"`` is stored as
92+
``"[::1]"``) so the assembled authority is unambiguous with a port.
93+
IPv4-mapped IPv6 is re-formatted to its canonical compressed form
94+
(``"::ffff:127.0.0.1"`` becomes ``"[::ffff:7f00:1]"`` — the same
95+
address, spelled canonically). Non-ASCII hostnames are IDNA-encoded
96+
to A-labels. IPv6 zone IDs (``"fe80::1%eth0"``) are rejected: RFC
97+
6874 requires the ``%`` be percent-encoded inside a URI, and
98+
silently emitting an invalid authority is worse than failing at
99+
wiring time.
100+
101+
The validation is deliberately **structural**, not a hostname-syntax
102+
check. ASCII names are accepted as-is once they cannot alter the
103+
URL's shape, because Docker Compose service names legally contain
104+
underscores (``my_service``, ``host_gateway``) which RFC 952/1123
105+
and IDNA both reject — and Docker's embedded DNS resolves them.
106+
Enforcing hostname syntax here would refuse the exact configuration
107+
this class exists to serve. Whether the name resolves is the
108+
resolver's business; whether it rewrites the signed URL is ours.
80109
"""
81110

82111
rewrite_to: str = "host.docker.internal"
83112

113+
def __post_init__(self) -> None:
114+
# Deferred import: ``adcp.signing``'s package __init__ is an
115+
# order of magnitude heavier than this leaf module, and both
116+
# real consumers (webhook_sender, webhooks) already import it.
117+
# This runs once per hook construction, never per delivery.
118+
from adcp.signing._idna_canonicalize import canonicalize_host
119+
120+
value = self.rewrite_to
121+
if not value:
122+
raise ValueError(
123+
"DockerLocalhostRewrite(rewrite_to=...) must be a hostname or IP "
124+
"literal; got an empty string"
125+
)
126+
127+
# Accept an already-bracketed IPv6 literal by unwrapping it first;
128+
# the brackets are re-applied below from the canonical form.
129+
inner = value[1:-1] if value.startswith("[") and value.endswith("]") else value
130+
if not inner:
131+
# `"[]"` survives the non-empty check above and empties here. An
132+
# empty host is the one outcome this guard exists to prevent: it
133+
# assembles to `https://:9000/hook`, an authority with a port and
134+
# no host -- exactly the shape @target-uri canonicalization rejects.
135+
raise ValueError(
136+
f"DockerLocalhostRewrite(rewrite_to=...) must be a hostname or IP "
137+
f"literal; got {value!r}, which has no host"
138+
)
139+
140+
# Structural rejection comes FIRST and is the actual point of this
141+
# guard: `rewrite_to` is interpolated straight into the netloc, so any
142+
# character that can move the boundary between authority, path, query,
143+
# fragment or userinfo rewrites the signed URL into a different URL.
144+
# `%` is here for RFC 6874 IPv6 zone IDs, which are not representable
145+
# in a URI authority without percent-encoding.
146+
bad = {ch for ch in inner if ch in _STRUCTURAL_CHARS or ord(ch) < 0x21 or ord(ch) == 0x7F}
147+
if bad:
148+
raise ValueError(
149+
f"DockerLocalhostRewrite(rewrite_to=...) must be a hostname or IP "
150+
f"literal; got {value!r}, which contains {sorted(bad)!r} and would "
151+
f"change the structure of the signed URL"
152+
)
153+
154+
try:
155+
ip = ipaddress.ip_address(inner)
156+
except ValueError:
157+
pass
158+
else:
159+
# Bracket v6 so the assembled authority is unambiguous with a port
160+
# -- the defect this guard exists to close. `str(ip)` also folds the
161+
# literal to its canonical compressed form.
162+
canonical = f"[{ip}]" if ip.version == 6 else str(ip)
163+
object.__setattr__(self, "rewrite_to", canonical)
164+
return
165+
166+
if ":" in inner:
167+
# Not an IP literal, so a colon is a port -- which `rewrite_url`
168+
# re-appends itself, and which `apply_hooks`' port guard would then
169+
# see as a port change.
170+
raise ValueError(
171+
f"DockerLocalhostRewrite(rewrite_to=...) must be a hostname or IP "
172+
f"literal without a port; got {value!r}"
173+
)
174+
175+
if inner.isascii():
176+
# Deliberately NOT routed through `canonicalize_host`: this is a
177+
# Docker helper, and Docker Compose service names legally contain
178+
# underscores (`my_service`, `host_gateway`), which IDNA rejects
179+
# under RFC 952/1123. Docker's embedded DNS resolves them, so
180+
# refusing them here would break the case this class exists for.
181+
# Structural safety is already established above; anything further
182+
# is the resolver's business, not ours.
183+
# One trailing root dot, matching `canonicalize_host` -- `rstrip`
184+
# would eat every dot, so `"."` and `".."` normalized to the empty
185+
# host rather than being rejected.
186+
ascii_host = inner.lower()
187+
if ascii_host.endswith("."):
188+
ascii_host = ascii_host[:-1]
189+
if not ascii_host or any(label == "" for label in ascii_host.split(".")):
190+
# Catches `"."`, `".."` and `"a..b"`. An empty label is not a
191+
# host, and `".."` in particular survives a single-dot strip as
192+
# `"."` -- non-empty, but still no host.
193+
raise ValueError(
194+
f"DockerLocalhostRewrite(rewrite_to=...) must be a hostname or IP "
195+
f"literal; got {value!r}, which normalizes to an empty label"
196+
)
197+
object.__setattr__(self, "rewrite_to", ascii_host)
198+
return
199+
200+
# Non-ASCII: convert to A-labels so the netloc is wire-legal. Failure
201+
# here is a genuinely unusable host, not a naming-convention quibble.
202+
try:
203+
canonical = canonicalize_host(inner)
204+
except (UnicodeError, ValueError) as exc:
205+
# ``idna.IDNAError`` subclasses ``UnicodeError``.
206+
raise ValueError(
207+
f"DockerLocalhostRewrite(rewrite_to=...) must be a hostname or IP "
208+
f"literal; got {value!r} ({exc})"
209+
) from exc
210+
object.__setattr__(self, "rewrite_to", canonical)
211+
84212
def rewrite_url(self, url: str) -> str | None:
85213
parsed = urlsplit(url)
86214
# ``hostname`` lower-cases and strips brackets from IPv6 — match

tests/conformance/signing/test_webhook_transport_hooks.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pytest
1212

1313
from adcp.signing import SSRFValidationError
14+
from adcp.signing.canonical import canonicalize_authority
1415
from adcp.webhook_sender import WebhookSender
1516
from adcp.webhook_transport_hooks import (
1617
DockerLocalhostRewrite,
@@ -65,6 +66,108 @@ def test_rewrite_preserves_query_and_fragment() -> None:
6566
)
6667

6768

69+
# ---------- rewrite_to validation ----------
70+
71+
72+
def test_bare_ipv6_rewrite_to_is_bracketed() -> None:
73+
"""A bare IPv6 ``rewrite_to`` yields an unbracketed authority that RFC 3986
74+
makes ambiguous with a port. Bracket it at construction — the operator's
75+
intent is unambiguous and the docstring encourages IP-literal values."""
76+
hook = DockerLocalhostRewrite(rewrite_to="::1")
77+
assert hook.rewrite_url("https://localhost:9000/hook") == "https://[::1]:9000/hook"
78+
79+
80+
def test_bare_ipv6_rewrite_survives_apply_hooks_and_signing_canonicalization() -> None:
81+
"""The bracketing must hold all the way through the framework's port guard
82+
and into signing canonicalization. Unbracketed, ``urlsplit(...).port`` inside
83+
``apply_hooks`` raises an opaque stdlib ValueError about a port the operator
84+
never configured."""
85+
hook = DockerLocalhostRewrite(rewrite_to="2001:db8::1")
86+
out = apply_hooks("https://localhost:9000/hook", (hook,))
87+
assert out == "https://[2001:db8::1]:9000/hook"
88+
assert canonicalize_authority(out) == "[2001:db8::1]:9000"
89+
90+
91+
@pytest.mark.parametrize(
92+
"bad",
93+
["", "attacker.com/path", "user@evil.example", "host.docker.internal:1234", "ho st"],
94+
)
95+
def test_rewrite_to_rejects_non_host_values(bad: str) -> None:
96+
"""``rewrite_to`` is interpolated into the netloc; anything that is not a
97+
hostname or IP literal changes the signed URL in ways apply_hooks' scheme/
98+
port guard does not catch (path injection, userinfo injection, raw spaces)."""
99+
with pytest.raises(ValueError, match="rewrite_to"):
100+
DockerLocalhostRewrite(rewrite_to=bad)
101+
102+
103+
def test_rewrite_to_rejects_ipv6_zone_id() -> None:
104+
"""``ipaddress`` accepts scoped addresses, but RFC 6874 requires the ``%``
105+
be percent-encoded as ``%25`` inside a URI. Rather than emit an authority
106+
that is invalid on the wire, reject the zone-ID form at construction."""
107+
with pytest.raises(ValueError, match="rewrite_to"):
108+
DockerLocalhostRewrite(rewrite_to="fe80::1%eth0")
109+
110+
111+
def test_bracketed_ipv6_rewrite_to_accepted_unchanged() -> None:
112+
hook = DockerLocalhostRewrite(rewrite_to="[::1]")
113+
assert hook.rewrite_url("https://localhost:9000/hook") == "https://[::1]:9000/hook"
114+
115+
116+
def test_rewrite_to_hostname_and_ipv4_still_accepted() -> None:
117+
assert (
118+
DockerLocalhostRewrite().rewrite_url("http://localhost:8080/webhook")
119+
== "http://host.docker.internal:8080/webhook"
120+
)
121+
assert (
122+
DockerLocalhostRewrite(rewrite_to="172.17.0.1").rewrite_url("http://localhost/x")
123+
== "http://172.17.0.1/x"
124+
)
125+
126+
127+
@pytest.mark.parametrize("bad", ["[]", "[.]", ".", "..", "...", "a..b"])
128+
def test_rewrite_to_rejects_values_that_normalize_to_no_host(bad: str) -> None:
129+
"""An empty host is the outcome this guard exists to prevent.
130+
131+
These reach the empty host by two different routes the earlier checks each
132+
miss: ``"[]"`` is non-empty until the brackets come off, and ``"."`` /
133+
``".."`` are non-empty until the trailing root dot is stripped. Both used
134+
to assemble to ``https://:9000/hook`` — an authority with a port and no
135+
host, which is precisely the shape ``@target-uri`` canonicalization
136+
rejects, produced by the hook meant to keep the authority well-formed.
137+
138+
``"a..b"`` is here because the fix is stated as "no empty label" rather
139+
than "not empty", and an interior empty label is the same defect.
140+
"""
141+
with pytest.raises(ValueError, match="rewrite_to"):
142+
DockerLocalhostRewrite(rewrite_to=bad)
143+
144+
145+
@pytest.mark.parametrize(
146+
"name", ["my_service", "host_gateway", "docker_host.local", "_dns-sd._udp.local"]
147+
)
148+
def test_rewrite_to_accepts_docker_legal_service_names(name: str) -> None:
149+
"""Underscored names must keep working -- this is a Docker helper.
150+
151+
Docker Compose service names legally contain underscores and Docker's
152+
embedded DNS resolves them, but RFC 952/1123 and IDNA both reject them.
153+
Validating ``rewrite_to`` as a *hostname* rather than structurally would
154+
refuse the exact configuration this class exists to serve, and would do it
155+
at construction -- turning a working deployment into a startup crash on
156+
upgrade. The guard only has to prove the value cannot restructure the URL.
157+
"""
158+
assert DockerLocalhostRewrite(rewrite_to=name).rewrite_to == name
159+
160+
161+
def test_rewrite_to_is_normalized_at_construction() -> None:
162+
"""Canonicalization is visible on the field: case-folded, trailing FQDN
163+
root dot dropped, IDN encoded to A-labels, IPv6 bracketed."""
164+
assert DockerLocalhostRewrite(rewrite_to="HOST.Docker.Internal.").rewrite_to == (
165+
"host.docker.internal"
166+
)
167+
assert DockerLocalhostRewrite(rewrite_to="bücher.example").rewrite_to == "xn--bcher-kva.example"
168+
assert DockerLocalhostRewrite(rewrite_to="[2001:DB8::1]").rewrite_to == "[2001:db8::1]"
169+
170+
68171
# ---------- apply_hooks (framework) ----------
69172

70173

0 commit comments

Comments
 (0)