fix: sanitize the download filename extension derived from ?filename=#539
fix: sanitize the download filename extension derived from ?filename=#539mattmillerai wants to merge 3 commits into
Conversation
The extension for a downloaded output was read straight off the untrusted, server-controlled ?filename= query param, so a hostile or compromised output server could put control/ANSI bytes into the on-disk name and into the saved path echoed to the terminal in human mode. Sanitize the derived suffix against a conservative alphanumeric whitelist (mirroring the existing _sanitize_item_name treatment of the item token), falling back to the same .png default already used for a suffix-less name. The local-source branch is left alone: a locally-produced filename is not a trust boundary. Path traversal was never possible here and still is not — Path(name).suffix only reads the last component's extension — and a test pins that.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 39 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 2 finding(s).
| Severity | Count |
|---|---|
| 🟢 Low | 2 |
Panel: 6/8 reviewers contributed findings.
Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)
The `where == "local"` branch derived `ext = local_source.suffix or ".png"`
unsanitized, so control/ANSI bytes in a suffix still reached the on-disk
name and the echoed saved path — the injection class the remote branch
already closes.
The PR's original rationale for leaving it (a local name comes from the
job's own on-disk output, so it is not a trust boundary) does not hold:
`_local_source_path` only *parses* a Path out of an `outputs` entry, and
that entry arrives from untrusted metadata (a piped stdin envelope's
`data.outputs`, or a cloud/remote `record`) — as execute_download's own
`is_local_job` comment notes. Nothing proves the string came from a real
local render.
Route both branches through `_sanitize_ext`. The `or ".png"` fallback is
preserved: `_sanitize_ext("")` already returns `.png`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Review pass — one finding fixed, one declined, CI triaged. Fixed (3176abe): the Declined: allowlisting output extensions. CI: Local: 1300 passed, 2 skipped across |
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
|
|
Second review pass — no code changes needed; PR stands as-is. Three things triaged: 1. The failing 2. The 3. Both call sites are covered. Swept every One follow-up filed from self-review. Local verification on this branch: full suite 2583 passed, 37 skipped; |
The lockfile refresh was swept in by a local 'uv run' (main's uv.lock is stale against pyproject's bench extra, so uv regenerates it on any run). It is unrelated to the sanitize-ext fix and is already the subject of dedicated PRs #541/#543, so it does not belong in this diff. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed
|
| tomlkit | multi-line .comment() |
|---|---|
0.15.1 |
ValueError: Comment cannot contain line breaks |
0.13.2 |
accepted |
pyproject.toml:49 pins tomlkit with no upper bound and CI installs via pip install -e ., so CI resolves the newest tomlkit and breaks. Worth noting this also means CI never reads uv.lock — so the lockfile churn above was never the cause either way. main was last green 2026-07-13 and these same 9 tests fail on every open PR. Not fixable from here; I have filed it as a separate high-priority follow-up so it can land on main and unblock the whole queue.
test (windows-latest) — ImportError: cannot import name '__version__' from 'pydantic_core' during Install Dependencies, i.e. a dependency-install failure before any of this code runs. Infrastructure, not the diff.
This PR cannot go green until the tomlkit fix lands on main; the code here is green locally (75 passed, 1 skipped; ruff format + check clean).
Self-review
Re-verified the two things a whitelist fix can get wrong. Over-denial: every real output extension passes through unchanged — .png .webp .jpg .jpeg .gif .mp4 .webm .flac .mp3 .wav .ogg .safetensors .latent .json .txt .glb .avif .mkv .mov .tiff .exr .pt .ckpt .sft .gguf — zero mangled, which is why the cap is 16 and not 10 (.safetensors is 12). Missed call sites: both extension-derivation sites in transfer.py (the remote ?filename= branch and the local local_source.suffix branch) now go through _sanitize_ext, and a repo-wide grep found no others. The filename at transfer.py:334 is a dict key for node lookup, never an on-disk name, so it is correctly untouched.
|
Correction to my comment above: the tomlkit fix already has a PR — #536 ( So the state of this PR is unchanged but the unblock path is concrete: #539 goes green once #536 lands on |
|
Status: no changes needed here — both red checks are pre-existing repo-wide breakages, not regressions from this PR. This diff touches only
Locally on this branch: |
ELI-5
When
comfy downloadsaves a file, it decides the file's extension (.png,.mp4, …) by reading the?filename=bit of the URL the output server gave it. Nobody was checking what came back. A hostile or compromised server could answer?filename=out.png<ESC>[31mHACKand those invisible escape bytes would end up in the real filename on your disk — and get printed straight to your terminal when the saved path is echoed. This makes the extension go through a "letters and numbers only" check first, and falls back to.pngwhen it doesn't pass.What changed
_sanitize_ext()helper next to the existing_sanitize_item_name(), mirroring its style: an extension is kept only if it fullmatches\.[A-Za-z0-9]{1,16}, otherwise it falls back to.png— the same default the callers already used for a suffix-less name.execute_downloadnow derives the remote extension as_sanitize_ext(Path(remote_name).suffix). Theitemtoken was already sanitized; the extension was the asymmetry.Scope — what this is and is not
This is Low severity and cosmetic: junk filenames plus terminal injection into human (non-JSON) output. It is not an arbitrary-write bug. Path traversal was never possible and still is not —
Path(remote_name).suffixonly reads the last path component's extension, so?filename=x.../../../etc/passwdyields.png, not a traversal.test_traversal_stays_impossiblepins that as a regression guard rather than implying it was ever broken.Judgment calls
.safetensorsis 12 characters — a 10-cap would silently rename a legitimate output to.png. The security property here is the character class (alphanumeric only, which is what excludes control/ANSI bytes); the length bound just stops absurd junk, so widening it costs nothing. I verified empirically that no real output extension the product emits is mangled:.png .webp .jpg .jpeg .gif .mp4 .webm .flac .mp3 .wav .ogg .safetensors .latent .json .txt .glb .avif .mkv .mov .tiff .exr .pt .ckptall pass through unchanged, andtest_long_real_extension_is_preservedlocks that in. Over-denial is the real risk of a whitelist, so it is worth pinning.The— CORRECTED: it is now sanitized too. I originally argued a locally-produced filename comes from the job's own on-disk output, so it is not a trust boundary. That was wrong, and review caught it.local_source.suffixbranch is deliberately NOT sanitized_local_source_pathonly parses aPathout of anoutputsentry — it never proves the string came from a real local render — and that entry arrives from untrusted metadata (a piped stdin envelope'sdata.outputs, or a cloud/remoterecord), exactly asexecute_download's ownis_local_jobcomment says two lines above the call site. Verified: anoutputsentry endingevil.png<ESC>[31mHACKproduced a real on-disk file carrying those bytes. Both branches now go through_sanitize_ext; theor ".png"fallback is preserved because_sanitize_ext("")already returns.png. Pinned bytest_copied_output_extension_is_sanitized, withtest_copied_output_keeps_real_extension_not_pngstill green so legitimate suffixes like.webpare untouched.main, not stacked on refactor(download): extract execute_download per-URL loop into _download_one_url / _copy_local_one / _stream_http_one (BE-3273) #531. This bug is pre-existing onmain(transfer.py:641); refactor(download): extract execute_download per-URL loop into _download_one_url / _copy_local_one / _stream_http_one (BE-3273) #531 is a byte-identical mechanical extraction whose whole reviewability argument is that runtime behavior is unchanged, so a real behavior change does not belong in it. The two touch the same lines and will need a trivial merge resolution whichever lands second — the sanitization is one call site either way.Noted, not fixed (out of scope)
The download error paths also echo the raw
urlback indetails/ the human-mode message, which is the same class of terminal-injection surface but a wider one than this ticket scopes. Not touched here; flagging it rather than silently expanding the diff.Testing
uv run --extra dev pytest tests/comfy_cli/command/test_transfer_download.py— 74 passed, 1 skipped.uv run ruff format --check— clean (235 files).uv run ruff check— 14 pre-existingUP038errors in untouched modules (workflow_to_api.py,oauth.py, …); zero in the files this PR changes.