Skip to content

lab-ops-hub: PR #533 decode-and-judge bypass — percent-encoded fullwidth delimiters NFKC-map into host delimiters after the decode check, so the container fetches unjudged private hosts (localhost / 127.0.0.1 / 192.168.0.1) #567

Closed

Description

@dezren39

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_url then 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: urlparse raises ValueError ("netloc … contains invalid characters under NFKC normalization") — verified this review: raw http://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%8f spellings.
  • 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

  1. added a commit that references this issue on Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions