Skip to content

fix: comfy download copies on-disk local outputs instead of refusing them (BE-2190)#485

Merged
bigcat88 merged 2 commits into
mainfrom
matt/be-2190-download-local-outputs
Jul 5, 2026
Merged

fix: comfy download copies on-disk local outputs instead of refusing them (BE-2190)#485
bigcat88 merged 2 commits into
mainfrom
matt/be-2190-download-local-outputs

Conversation

@mattmillerai

Copy link
Copy Markdown
Collaborator

ELI-5

If you run a workflow locally and then try to comfy download its result, ComfyUI already wrote the image to your disk — there's no web address to fetch. But download is a web-fetcher with a safety guard that refuses anything that isn't an http(s):// link, so it choked on the plain file path with refusing to download from non-HTTP URL: /…/output/foo.png. This teaches download to notice "hey, that's already a file on disk" and just copy it into your output folder instead of trying to fetch it — while keeping the safety guard fully in force for real remote URLs.

What & why

Fixes #480.

A comfy run --where local --wait job records bare absolute output paths (run/execution.py::format_image_path returns an on-disk path for loopback hosts). comfy download fed those through _assert_download_url, whose anti-SSRF guard rejects any non-http(s) URL → [download_failed]: refusing to download from non-HTTP URL.

The fix (additive — SSRF guard untouched for real URLs)

  • New _local_source_path(url) returns a Path for a bare absolute filesystem path or a file:// URL with no remote host, and None for anything carrying a network scheme (http/https/ftp/gopher/data/…). A file:// URL pointing at a remote host returns None too, so it still hits the guard.
  • In the download loop: when the source is local, shutil.copyfile it into --out-dir (no HTTP request, so the SSRF guard is irrelevant — there is no host to forge a request to). Everything else still runs through _assert_download_url exactly as before.
  • Extension fix (latent bug called out in comfy download refuses local runs: "refusing to download from non-HTTP URL" for on-disk output paths from comfy run --where local --wait #480): a bare path has no ?filename= query param, so the old code mislabeled every local output .png. The copied file now takes its extension from the real source file.
  • The destination-symlink refusal now runs for both branches (copy and fetch); a missing local source produces a clean download_failed envelope, not a traceback.

Acceptance criteria

  1. comfy download <prompt_id> --where local succeeds for an on-disk output, copying it into --out-dir and returning its path.
  2. _assert_download_url still rejects non-http(s) URLs on the remote/download path — tests cover both the local-copy branch and a rejected non-http URL (ftp://, remote-host file://, gopher://, data:) end-to-end through the loop.
  3. ✅ Copied local outputs keep their real extension (.webp, .mp4, …), not hardcoded .png.

Testing

  • Added TestLocalOutputCopy (bare path copy, extension preservation, file:// URI, missing-source error) and TestSSRFGuardIntact (guard still rejects non-http; remote-host file:// not treated as local; non-http rejected end-to-end through the loop).
  • pytest tests/comfy_cli/command/1066 passed, 1 skipped.
  • ruff check + ruff format --check clean.

Judgment calls

  • Local detection uses Path(url).is_absolute() (platform-aware for POSIX /… and Windows C:\…) after ruling out the file:// scheme. Network URLs are never absolute paths, so they always fall through to the SSRF guard. A protocol-relative //host/path would be treated as a local read (not a network fetch) and simply fail is_file() with a clean error — not an SSRF vector, and not a form the producer emits.
  • The emitted files[].url for a local output is the bare source path (provenance); the transfer schema types url as an unconstrained string, so this validates.

🤖 Generated with Claude Code

…them (BE-2190)

A LOCAL run (comfy run --where local --wait) records bare absolute output
paths, but comfy download is an HTTP fetcher whose anti-SSRF guard rejected
any non-http(s) URL, so downloading a local job failed with
'refusing to download from non-HTTP URL'.

Detect a bare absolute path / file:// URL in the download loop and
shutil.copyfile it into --out-dir before the HTTP guard runs; real URLs
still go through _assert_download_url, so SSRF protection is unchanged. The
copied file's extension is derived from the source file (a bare path has no
?filename= param, so the prior code mislabeled every local output .png).
@mattmillerai mattmillerai added agent-coded PR authored by the agent-work loop cursor-review Request Cursor bot review labels Jul 2, 2026
@mattmillerai mattmillerai marked this pull request as ready for review July 2, 2026 04:48
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 2, 2026
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 78957fe3-a73c-406e-a858-4e85996f4a95

📥 Commits

Reviewing files that changed from the base of the PR and between a628223 and eb3d157.

📒 Files selected for processing (2)
  • comfy_cli/command/transfer.py
  • tests/comfy_cli/command/test_transfer_download.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch matt/be-2190-download-local-outputs
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch matt/be-2190-download-local-outputs

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 6 finding(s).

Severity Count
🟠 High 2
🟡 Medium 2
🟢 Low 1
⚪ Nit 1

Panel: 8/8 reviewers contributed findings.

Comment thread comfy_cli/command/transfer.py Outdated
Comment thread comfy_cli/command/transfer.py
Comment thread comfy_cli/command/transfer.py Outdated
Comment thread comfy_cli/command/transfer.py Outdated
Comment thread comfy_cli/command/transfer.py
Comment thread comfy_cli/command/transfer.py Outdated
…2190)

Address review findings on the copy-from-disk download branch:

- Gate the copy-from-disk branch on the job's own `where == "local"`
  marker, not on URL shape alone. `output_urls` can arrive from
  untrusted metadata (piped stdin envelope, cloud/remote API record), so
  a bare path / file:// URL from a non-local job must fall through to the
  SSRF guard instead of being copied off disk (was: 6/8 reviewers).
- Reject UNC / network paths (\\host\share, //host/share) in
  _local_source_path so is_file() can't trigger an outbound NTLM-leaking
  SMB connection on Windows.
- Use parsed.hostname (not netloc) for the loopback check so a port or
  IPv6 brackets (file://localhost:8080/…, file://[::1]/…) don't defeat it.
- Wrap shutil.copyfile in try/except: OSError and SameFileError now
  surface as structured download_failed envelopes instead of tracebacks.
- Apply the HTTP branch's 10 GB size cap to the local copy too.

Add regression tests for the local-job gate, UNC rejection, and
loopback-with-port file:// URIs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@bigcat88 bigcat88 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and tested locally — the hardening here is genuinely careful (the where=="local" gate, UNC / remote-file:// rejection, hostname-based loopback check, dest-symlink refusal shared across both branches, the 10 GB cap via a pre-copy stat(), structured error envelopes). Approving. One thing worth tightening, here or as an immediate follow-up:

Piped data.outputs are copied off disk without the workspace-containment check

The copy-from-disk branch gates on is_local_job (from the trusted state file), but the paths it copies are output_urls — and when input is piped, output_urls = piped_urls from stdin, which takes precedence over state.outputs (transfer.py:437-438). Those two are decided independently, so a piped envelope that reuses a real local prompt_id but points outputs at an arbitrary absolute path gets copied, bypassing the <workspace>/<type>/ containment that format_image_path enforces on genuine local outputs.

Reproduced through the real CLI — a file from outside any workspace lands in the out-dir:

# victim has a completed local job "abc123" in ~/.config/comfy-cli/jobs/
$ echo '{"ok":true,"data":{"prompt_id":"abc123","outputs":["/home/victim/.ssh/id_rsa"]}}' \
    | comfy --json download --out-dir ./out
# → ./out/abc123_000.png  == contents of id_rsa

Impact: confused-deputy arbitrary file read into the (attacker-observable) out-dir.

Preconditions (narrow, which is why this isn't a blocker): the attacker must control comfy download's stdin (no prompt_id arg passed) and supply a prompt_id that matches an existing local job — a server-minted UUID, so it doesn't leak on its own. The normal comfy run --where local --wait --json | comfy download pipe is unaffected.

Flagging it because on main every bare path was rejected, so this PR adds the surface, and the inline comment at transfer.py:504-508 reads as if the gate fully closes it ("untrusted data.outputs … must NOT bypass the SSRF guard") when it only does so for a non-matching prompt_id.

Suggested fix — only copy paths the trusted state file itself recorded:

is_local_job = state is not None and getattr(state, "where", None) == "local"
trusted_outputs = set(state.outputs) if is_local_job and state.outputs else set()
...
local_source = _local_source_path(url) if (is_local_job and url in trusted_outputs) else None

The non-piped path is unchanged (output_urls == state.outputs, so every URL qualifies), and the legit local pipe still works — run/__init__.py writes state.outputs = list(execution.outputs) and emits the same list(execution.outputs) into data.outputs, so piped_urls == state.outputs element-for-element. Injected paths that aren't in state.outputs fall through to _assert_download_url and get rejected.

I validated this locally: full tests/comfy_cli/command/ stays green (1073 passed), and across a 6-scenario matrix the change flips only the two injection paths from copied→rejected while the legit pipe, non-piped download, mixed bare/http outputs, and cloud fetch all behave identically. Happy to push it + a regression test as a follow-up if you'd rather land the PR as-is.

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Jul 5, 2026
@bigcat88 bigcat88 merged commit 4010614 into main Jul 5, 2026
17 checks passed
@bigcat88 bigcat88 deleted the matt/be-2190-download-local-outputs branch July 5, 2026 20:50
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

agent-coded PR authored by the agent-work loop cursor-review Request Cursor bot review lgtm This PR has been approved by a maintainer size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

comfy download refuses local runs: "refusing to download from non-HTTP URL" for on-disk output paths from comfy run --where local --wait

3 participants