Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions pkgs/lab-ops-hub/container/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,13 @@ def validate_crawl_url(raw):
# A single trailing dot is the DNS root dot (the FQDN spelling of the
# same host: "localhost." is "localhost", "127.0.0.1." is 127.0.0.1);
# strip it before the checks so the dotted spelling cannot evade
# them, matching the Worker's validateCrawlUrl (#466).
if host.endswith("."):
# them, matching the Worker's validateCrawlUrl (#466). Remember
# that it was there: the percent-decoded return path below rebuilds
# the URL from this stripped host, and without the dot it returned
# a different URL than the literal-dot spelling (and the Worker)
# for the same target (#647).
has_root_dot = host.endswith(".")
if has_root_dot:
host = host[:-1]
if not host:
raise ValueError("url has no host")
Expand Down Expand Up @@ -397,10 +402,17 @@ def validate_crawl_url(raw):
)
# A public DNS name; literal-IP policy does not apply.
# A percent-decoded host is fetched in its decoded form.
# The returned form is WHATWG-normalized (#633).
# The returned form is WHATWG-normalized (#633). The root
# dot judging stripped is re-attached, so the percent-
# encoded spelling returns the same dotted form as the
# literal spelling and the Worker (#647). (IP literals
# take the canonical-address return below instead, and
# WHATWG serializes those without a root dot, as here.)
return _url_with_encoded_components(
_normalize_validated_url(
_url_with_host(u, host, raw) if percent_decoded else raw
_url_with_host(u, host + "." if has_root_dot else host, raw)
if percent_decoded
else raw
)
)
embedded = _embedded_ipv4(ip)
Expand Down
24 changes: 24 additions & 0 deletions pkgs/lab-ops-hub/test/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,30 @@ test("validateCrawlUrl returns dot segments, host case and ports normalized (#63
}
});

test("validateCrawlUrl returns the root dot for both dot spellings (#647)", () => {
// The Worker's returned form is the reference the container validator
// mirrors: WHATWG host parsing decodes "%2e" to "." and serialises
// the DNS root dot back, so the literal and percent-encoded spellings
// return the same dotted URL. (IP literals serialise without one.)
const cases: Array<[string, string]> = [
["http://example.com./", "http://example.com./"],
["http://example.com./path", "http://example.com./path"],
["http://example.com%2e/", "http://example.com./"],
["http://example.com%2E/", "http://example.com./"],
["http://example.com%2e/path", "http://example.com./path"],
["http://sub.example.com%2e/", "http://sub.example.com./"],
["http://8.8.8.8./", "http://8.8.8.8/"],
["http://8.8.8.8%2e/", "http://8.8.8.8/"],
];
for (const [raw, expected] of cases) {
const r = validateCrawlUrl(raw);
assert.equal(r.ok, true, raw);
if (r.ok) assert.equal(r.url, expected, raw);
}
assert.equal(validateCrawlUrl("http://localhost%2e/").ok, false);
assert.equal(validateCrawlUrl("http://example.com%2e%2e/").ok, false);
});

test("validateCrawlUrl returns empty components normalized (#639)", () => {
// The Worker's returned form is the reference the container validator
// mirrors: "/" supplied for an empty path, the empty port's colon and
Expand Down
39 changes: 39 additions & 0 deletions pkgs/lab-ops-hub/test/test_container_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,45 @@ def test_returns_empty_components_normalized(self):
)
self.assertEqual(followed.full_url, "http://example.org/")

def test_returns_the_root_dot_for_percent_encoded_spellings(self):
# The container stripped the DNS root dot before judging and
# rebuilt the returned URL from the stripped host, so the
# percent-encoded spelling came back without the dot while the
# literal spelling (returned raw) and the Worker kept it —
# the same target was recorded under two different URLs
# depending on the spelling (#647). The judged host is still
# the stripped one; only the returned form re-attaches the dot.
cases = {
# Literal spellings (controls: unchanged).
"http://example.com./": "http://example.com./",
"http://example.com./path": "http://example.com./path",
# Percent-encoded spellings now match them, and the Worker.
"http://example.com%2e/": "http://example.com./",
"http://example.com%2E/": "http://example.com./",
"http://example.com%2e": "http://example.com./",
"http://example.com%2e/path": "http://example.com./path",
"http://sub.example.com%2e/": "http://sub.example.com./",
"http://example.com%2e:8080/path": "http://example.com.:8080/path",
"http://user@example.com%2e/": "http://user@example.com./",
"http://example.com%2e?#": "http://example.com./?#",
# A percent-encoded U+3002 maps to the same root dot.
"http://example.com%E3%80%82/": "http://example.com./",
# IP literals serialize without a root dot in WHATWG, and
# both validators already agreed there — unchanged.
"http://8.8.8.8./": "http://8.8.8.8/",
"http://8.8.8.8%2e/": "http://8.8.8.8/",
"http://8%2e8%2e8%2e8%2e/": "http://8.8.8.8/",
}
for raw, expected in cases.items():
self.assertEqual(validate_crawl_url(raw), expected, raw)
# Verdicts are unchanged: the dotted spellings of blocked
# targets stay refused, and a second dot is still an empty
# label, in either spelling.
self.assertRefused("http://localhost%2e/")
self.assertRefused("http://localhost./")
self.assertRefused("http://example.com../")
self.assertRefused("http://example.com%2e%2e/")

def test_returns_empty_password_userinfo_without_the_colon(self):
# WHATWG serialises userinfo from the parsed username/password:
# the ":" separator appears only when the password is non-empty,
Expand Down
Loading