fix(http): pin the remaining credential-carrying urllib openers to http(s) handlers only (BE-3298)#542
Conversation
…BE-3298) `urllib.request.build_opener()` implicitly installs FileHandler, FTPHandler and DataHandler, so an opener that attaches Bearer / X-API-Key could be steered into reading unintended local content via a file:// or ftp:// URL. BE-3274 pinned `_AUTHED_OPENER`; the same pattern remained at five other credential-carrying openers. Generalize that PR's private `_build_authed_opener()` into a reusable `build_http_only_opener(*handlers)` and route every opener through it, preserving each site's existing handler argument. `_DOWNLOAD_OPENER` keeps its redirect-following `_DownloadRedirectHandler` — only its scheme set is pinned. Not known to be exploitable: every call site builds its URL from a trusted base_url or is already scheme-guarded upstream. This is the remaining scheme-restriction half of the same defense-in-depth.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 3 finding(s).
| Severity | Count |
|---|---|
| 🟡 Medium | 1 |
| 🟢 Low | 2 |
Panel: 6/8 reviewers contributed findings.
Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)
…pener (BE-3298) Per cursor-review panel on #542: - ProxyHandler() defaults to getproxies() and registers a <scheme>_open for every entry, so an ftp_proxy in the environment gave the opener an ftp_open that won dispatch over UnknownHandler — servicing ftp:// through the proxy and defeating the scheme pinning this PR exists to enforce. Verified reproducible before the fix. The proxy map is now filtered to http(s); no_proxy/proxy_bypass read the environment directly, so bypass still works. - Guard HTTPSHandler on hasattr: urllib.request only defines it on an SSL-capable build, so naming it unconditionally raised AttributeError at import time on one without, taking the whole module with it. - Mirror build_opener's override semantics: a caller-supplied handler now replaces the default it subclasses instead of being appended behind it, where it would silently never be consulted. No current caller is affected: every opener still dispatches exactly http/https/unknown, and http(s) proxying is unchanged (asserted end-to-end against a live proxy). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
✅ No high-signal findings.
Panel: 6/8 reviewers contributed findings.
Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ELI-5
urllib.request.build_opener()quietly hands you more than HTTP: it also installsFileHandler,FTPHandlerandDataHandler. So an opener that attaches yourAuthorization: Bearer …/X-API-Keywill happily readfile:///etc/passwdif acaller is ever steered into such a URL. #530 pinned
_AUTHED_OPENERto http(s) only;this PR does the same for the five other credential-carrying openers that were outside
that PR's diff.
What changed
#530's private_build_authed_opener()is generalized into a reusablebuild_http_only_opener(*handlers)incomfy_cli/http.py. It installsProxyHandler,HTTPHandler,HTTPSHandler,HTTPDefaultErrorHandler,HTTPErrorProcessor,UnknownHandlerplus the caller's handlers — and_AUTHED_OPENERis re-expressed in terms of it (no behavior change).Each opener now builds through it, keeping its existing handler argument
(
transfer.pykeeps its custom refusal message; the rest pass a bareNoRedirectHandler()):_OPENERcomfy_cli/comfy_client.py_openercomfy_cli/cql/engine.py_LOADER_OPENERcomfy_cli/cql/loader.py_OAUTH_OPENERcomfy_cli/cloud/oauth.py_TRANSFER_OPENERcomfy_cli/command/transfer.py_DOWNLOAD_OPENERis scheme-pinned too but keeps following redirects — its_DownloadRedirectHandler(which strips auth headers and re-checks the scheme oneach hop) is intentional. A test locks that in so a future cleanup can't silently
convert it to no-redirect.
tests/comfy_cli/test_http_only_openers.pymirrors fix: route authed urllib paths through a shared NoRedirectHandler opener (BE-3274) #530's two checks across allopeners, parametrized:
file:///ftp:///data:raiseURLError("unknown url type"), and the handler set excludes File/FTP/Data.Proxies are filtered to http(s) (
_http_only_proxy_handler()). BareProxyHandler()reads
getproxies()and registers a<scheme>_openper entry, so anftp_proxyin theenvironment handed the opener an
ftp_openthat won dispatch overUnknownHandler—servicing
ftp://through the proxy and defeating the whole point of this PR. Caught bythe review panel; confirmed reproducible before the fix. http(s) proxy support is
preserved (and
no_proxy/proxy_bypassread the environment directly, not this dict).HTTPSHandleris installed conditionally:urllib.requestonly defines it on anSSL-capable build, so naming it unconditionally raised
AttributeErrorat import timeon a Python built without SSL, taking the whole module with it.
Why this is safe (no capability is actually denied)
The diff's user-visible effect is a refusal, so I went looking for any path where a
non-http scheme reaches these openers today. There is none — every one either builds
its URL itself or is scheme-guarded upstream:
transfer.pydownload —_assert_download_url()already rejects non-http(s)before the opener (
transfer.py:705), andfile://for local jobs is handled bya separate filesystem branch (
_local_source_path()→_copy_local_output_capped())that never touches an opener. So local-output copying is untouched, and
ftp://already failed with a clearer error than mine.
cql/loader.py— the file path goes through_load_from_file()(aPathread);_LOADER_OPENERonly ever opens a hard-codedhttp://{host}:{port}/object_infoloopback URL.
cql/engine.py,comfy_client.py,cloud/oauth.py— URLs come from a trustedtarget.base_urlplus validated path segments, never a server-returned value.This is defense-in-depth for a future caller, not a live exploit fix — matching #530's framing.
Judgment calls
This PR is stacked on fix: route authed urllib paths through a shared NoRedirectHandler opener (BE-3274) #530 (base
matt/be-3274-authed-urlopen), because the_build_authed_opener()helper it generalizes only exists there. GitHub willretarget to
mainwhen fix: route authed urllib paths through a shared NoRedirectHandler opener (BE-3274) #530 merges; review sees only the net diff.Tests live in one parametrized module rather than five near-identical copies
scattered across each module's test file — this is a cross-module invariant, and a
table-driven test means the next opener added gets covered by appending one line.
build_http_only_opener()mirrorsbuild_opener's override semantics. I originallyleft this out as speculative machinery, but review pushed back and was right: the failure
mode is silent (a caller-supplied cert-pinning
HTTPSHandlerwould be accepted and thennever consulted, because the default's
https_openwins dispatch). It's ~4 lines, so acaller-supplied handler now replaces the default it subclasses.
Out of scope, flagged not fixed (follow-up proposed):
comfy_cli/command/templates.py(:51, :413) andcomfy_cli/command/jobs.py(:87, :995, :1019) call bareurllib.request.urlopen(),which uses the global default opener — File/FTP/Data handlers and all. That's the same
class of gap but not in this ticket's enumerated five; happy to file a follow-up.
Verification
pytest tests/ -q→ 2625 passed, 37 skippedruff check ./ruff format --check .→ clean, using CI's pinnedruff==0.15.15.Heads-up: the
devextra is unpinned and resolves to ruff 0.12.7 locally, which reports14 pre-existing
UP038errors onmaintoo (the rule was removed in newer ruff) — notfrom this diff, and CI's pinned version is green.
Review round 2
The cursor-review panel found a real hole in the first cut, all three now fixed in 2774121:
ftp_proxysmuggledftp://back in (medium). Reproduced before fixing — the openerreally did gain an
ftpdispatch key. Every opener now dispatches exactly['http', 'https', 'unknown']even withftp_proxy/all_proxyset.HTTPSHandleron an SSL-less build — nowhasattr-guarded.build_opener.Re-verified after the fix: full suite green, and http(s) proxying checked end-to-end against
a live local proxy (it receives the absolute URI and serves the response), plus
no_proxybypass still declines to direct. So the added refusal costs no real capability.
Note:
pytest.yml/ruff_check.yml/CodeRabbit don't run on this PR because it's stacked onmatt/be-3274-authed-urlopenrather thanmain; the suite and CI's pinned ruff were runlocally instead. They'll run once #530 merges and this retargets.