diff --git a/pkgs/lab-ops-hub/container/app.py b/pkgs/lab-ops-hub/container/app.py index 8702c414e..3f39b6e7c 100644 --- a/pkgs/lab-ops-hub/container/app.py +++ b/pkgs/lab-ops-hub/container/app.py @@ -56,6 +56,12 @@ def validate_crawl_url(raw): # the zone id). Refuse every % host, fail-closed (#505). if "%" in host: raise ValueError(f"refusing to crawl percent-encoded host {host}") + # A space or control character in the host is not a DNS name: WHATWG + # rejects it outright, urlparse hands it through verbatim, and + # http.client only raises for it deep inside the fetcher — the same + # lazy-failure shape #502 fixed for ports. Refuse up front (#507). + if any(ord(c) <= 0x20 for c in host): + raise ValueError(f"refusing to crawl host with whitespace {host!r}") if host == "localhost" or host.endswith(_BLOCKED_SUFFIXES): raise ValueError(f"refusing to crawl local host {host}") try: @@ -76,6 +82,17 @@ def validate_crawl_url(raw): f"refusing to crawl host {host}: numeric final label " "but not a valid IPv4 address" ) + # A bracketed host occupied the IP-literal position + # ([v1.fe80::], [v7.a] and other IPvFuture forms) but is not + # a valid IPv6 literal — urlparse strips the brackets, so + # look at the netloc. Fail closed instead of reclassifying + # it as a public DNS name, matching the Worker's validator + # (#507). + if u.netloc.rsplit("@", 1)[-1].startswith("["): + raise ValueError( + f"refusing to crawl host {host}: bracketed literal " + "but not a valid IPv6 address" + ) return raw # a public DNS name; literal-IP policy does not apply embedded = _embedded_ipv4(ip) if embedded is not None: diff --git a/pkgs/lab-ops-hub/test/test_container_validate.py b/pkgs/lab-ops-hub/test/test_container_validate.py index f8dbf116d..484191ef2 100644 --- a/pkgs/lab-ops-hub/test/test_container_validate.py +++ b/pkgs/lab-ops-hub/test/test_container_validate.py @@ -192,6 +192,22 @@ def test_refuses_percent_encoded_hosts_and_zone_ids(self): ): self.assertRefused(url) + def test_refuses_space_hosts_and_ipvfuture_literals(self): + # A space in the host is not a DNS name: WHATWG rejects it and + # http.client only raises for it deep inside the fetcher, so the + # validator refuses up front. A bracketed host that is not a + # valid IPv6 literal (IPvFuture forms) occupied the IP-literal + # position; a DNS name never contains a colon, so it must not be + # reclassified as a public DNS name either (#507). + for url in ( + "http://exam ple.com/", + "http://exa mple.com/", + "http://[v1.fe80::]/", + "http://[v7.a]/", + "http://[vf.FF]/", + ): + self.assertRefused(url) + def test_accepts_public_targets(self): for url in ( "https://example.com/page",