Repository navigation
Description
Finding
PR #533 (merge 06900e7c, commit 00de5b87) replaced the container crawl validator's blanket refusal of % hosts with decode-and-judge — but the delimiter check runs on the percent-decoded form before NFKC mapping, so a percent-encoded character whose NFKC mapping is a host delimiter escapes the check, is spliced into the returned URL, and makes the fetcher connect to a host that was never judged — including localhost, 127.0.0.1 and 192.168.0.1.
Evidence
In pkgs/lab-ops-hub/container/app.py on current main (87624089):
_percent_decode_host(line ~271) refuses a decoded%and any decoded character in#/:?@[\].validate_crawl_urlthen NFKC-normalizes the decoded host (unicodedata.normalize("NFKC", host).lower(), line ~118) and runs the remaining checks on the mapped form — but no check after mapping refuses/ \ : ? @ # [ ]or a mapped%. The forbidden-character check only covers controls/space,0x7F–0xA0,<>^|and_IDNA_DISALLOWED.- Raw fullwidth spellings cannot reach this state:
urlparseraisesValueError("netloc … contains invalid characters under NFKC normalization") — verified this review: rawhttp://127.0.0.1/example.com/is BLOCKED. The percent-encoded spelling (%ef%bc%8f= UTF-8 for U+FF0F FULLWIDTH SOLIDUS) is pure ASCII in the raw netloc, so that guard never fires.
Verified by driving both real validators from a git archive snapshot of current main:
| URL | Container (Python) | Worker (TS / WHATWG) |
|---|---|---|
http://localhost%ef%bc%8fexample.com/ |
ALLOW → returns http://localhost/example.com/ (fetcher host: localhost) |
BLOCK "url is not a valid URL" |
http://127.0.0.1%ef%bc%8fexample.com/ |
ALLOW → returns http://127.0.0.1/example.com/ |
BLOCK |
http://192.168.0.1%ef%bc%8fadmin/ |
ALLOW → returns http://192.168.0.1/admin/ |
BLOCK |
http://exam%ef%bc%8fple.com/ (U+FF0F→/) |
ALLOW → http://exam/ple.com/ |
BLOCK |
http://exam%ef%bc%9aple.com/ (U+FF1A→:) |
ALLOW → http://exam:ple.com/ |
BLOCK |
http://exam%ef%bc%a0ple.com/ (U+FF20→@) |
ALLOW → http://exam@ple.com/ |
BLOCK |
http://exam%ef%bc%83ple.com/ (U+FF03→#), %ef%bc%9f (?), %ef%bc%bc (\), %ef%bc%bb/%bd ([,]) |
ALLOW, delimiter spliced into returned URL | BLOCK |
http://example%ef%bc%85com/ (U+FF05→%) |
ALLOW → http://example%com/ (a % the decode step guarantees cannot survive) |
BLOCK |
http://%e2%84%80.com/ (U+2100 ℀, NFKC expands to a/c) |
ALLOW → http://a/c.com/ |
BLOCK |
Controls behave as PR #533 intended: %65xample.com and 8%2e8%2e8%2e8 ALLOW in both, %6cocalhost BLOCK in both, ASCII delimiters (%2f, %5c, %3a) BLOCK in Python at the decode step.
The exposed path is redirects: _ValidatingRedirectHandler validates the raw Location with no Worker normalisation in between (PR #533's own framing), and _url_with_host returns the mapped host spliced into the URL — so a public page can 302 the container to http://192.168.0.1%ef%bc%8fadmin/ and the container fetches the private host. This also breaks PR #533's own guarantee that the crawler fetches "the decoded host that was judged".
Current-main suites pass (TS 25/25, Python 22/22, re-run this review) — the battery pins ASCII decoded delimiters only; no percent-encoded compatibility/fullwidth delimiter case exists in either suite.
Why it matters
This is an SSRF-policy bypass in the component that actually fetches, introduced by the merged PR that set out to fix validator parity (#529): the Worker refuses these spellings outright while the container now accepts and fetches an unjudged, possibly private, target — the same failure shape as #468 and the #505 blanket refusal this PR superseded.
Acceptance criteria
- The container refuses every percent-encoded spelling whose decoded and NFKC-mapped form contains a host delimiter (
% # / : ? @ [ \ ]and the rest of the WHATWG forbidden-host set) — e.g. by re-checking structural characters on the fully mapped form, or refusing decoded codepoints whose NFKC mapping introduces one. - Verdict parity with the Worker on a shared battery including at least: U+FF0F, U+FF1A, U+FF20, U+FF03, U+FF1F, U+FF3C, U+FF05 and U+2100 cases, plus
localhost/127.0.0.1/192.168.0.1+%ef%bc%8fspellings. - For every accepted percent-encoded URL, re-parsing the returned URL yields exactly the host that was judged (asserted in tests).
- Regression tests in
test_container_validate.py(including a redirect-handler refusal for a percent-encoded fullwidth-delimiter target) and, where the vector format allows,lib.test.ts. - Existing suites still pass (TS 25/25, Python 22/22 plus the new tests).
Activity
Merge pull request #570 from developing-today/sweep/567-nfkc-mapped-d…