fix(output): strip ANSI/control sequences from server-supplied text at the pretty renderer boundary (BE-4794) - #614
Conversation
…derer boundary (BE-4794) Server-supplied strings reach the terminal verbatim in pretty mode. A remote or shared ComfyUI (comfy run --host/--port) picks the prompt_id and writes the exception text the CLI interpolates into error output, and Rich does not save us: rich.text.Text drops a few C0 characters but passes ESC straight through, so a CSI/OSC sequence can clear the screen, rewrite the window title, or repaint earlier lines to spoof CLI output. Add comfy_cli/output/sanitize.py (C0/C1 control characters plus CSI/OSC/DCS escape sequences, keeping tab and newline) and apply it once at the pretty rendering boundary: error_panel's code/message/hint/details, and Renderer's info/warn/success. The JSON and NDJSON envelope paths are untouched -- json.dumps already escapes ESC, and stripping there would mutate the data agents parse.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 9 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 (14)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 4 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 2 |
| 🟢 Low | 2 |
Panel: 6/8 reviewers contributed findings.
Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)
…ites (BE-4794) Stripping the escape *bytes* was only half the boundary: Rich manufactures new ones from markup it finds in the string, so the sanitizer was bypassable. - Add `sanitize_markup` (sanitize_value + `rich.markup.escape`) and use it wherever text reaches a markup-parsing sink: `Renderer.info/warn/success` (+ their hints) and the `details` rows `error_panel` hands to `Table.add_row`. Verified: a server-supplied `[link=https://attacker.example]x[/link]` rendered as a live OSC 8 hyperlink (`\x1b]8;...`), and an unbalanced `[/]` raised `rich.errors.MarkupError`, crashing the CLI while it was merely printing an info line. Values passed to `rich.text.Text` (code/message/hint) keep plain `sanitize_*` — `Text` never parses markup, so escaping there would show the backslashes. - Drop the now-redundant `rich.markup.escape` in `outdated.execute`: double-escaping renders a visible stray backslash. Escaping at the boundary rather than per call site is the point of having a boundary; callers that genuinely want markup still have `renderer.print()`, the documented passthrough. No call site passed markup to these helpers. - `_ESCAPE_SEQUENCE_RE`: add SOS (`\x98`) to the 8-bit C1 string-introducer class, so `\x98payload\x9c` is consumed as a unit instead of leaving `payload` as visible garbage — matching the 7-bit `\x1bX` form. - `error_panel` code/message use `sanitize_value`, and `sanitize_optional` coerces non-strings, so a caller ignoring the `str` annotation gets `str()` coercion rather than a `TypeError` from `re.sub`. Tests: 15 new cases, all verified to fail without the source fix. Realistic messages (`list[0]`, `[2/5] downloading`, Windows paths, URLs) render byte-identically. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These assert on an un-wrapped substring, and `rich.print` gives no way to pass `width` through, so the result otherwise depends on the runner's default console width. The error-panel case fit in 80 columns with only ~10 to spare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed all 4 findings from the Cursor panel (2 High, 2 Low) in f469172 + 3ce16d1. All four threads replied to and resolved. The High findings were real, and they shared one root cause. The PR stripped the escape bytes, but Rich manufactures new ones from markup it finds in the string afterwards — so the sanitizer was bypassable. Reproduced both halves before fixing:
Fix: new I moved the escaping into the boundary rather than the call sites deliberately. The original design asked every call site to remember The tradeoff, checked rather than assumed: these helpers no longer accept caller markup. An exhaustive grep of Also verified no cosmetic regression: Low findings: added SOS ( Tests: 15 new cases, each verified to fail with the source fix stashed (not vacuous). Console width is pinned in the markup tests since No deferrals — everything raised was fixable in scope. |
bigcat88
left a comment
There was a problem hiding this comment.
The sanitizer itself is excellent and I could not break it. What I can break is the claim that it closes the hole — the headline attack from your own ELI-5 (comfy run --host/--port pointed at a stranger's machine) still clears my screen and sets my window title on this branch. Requesting changes so BE-4794 doesn't close on a threat that still works, not because anything here is wrong.
What I verified works. sanitize() neutralized all 13 vectors I threw at it — CSI 2J, OSC 0 title-set, OSC 8 hyperlink, CR repaint, 8-bit C1 introducer, DCS, APC, unterminated OSC, lone trailing ESC, two-/three-char escapes, NUL/BEL/DEL, raw C1 block — with zero leaks, idempotent, and legitimate text (newlines, tabs, unicode, literal [brackets]) untouched. ReDoS claim holds: 400 KB adversarial inputs in 0.4–6.8 ms. Your non-vacuity claim is real — I confirmed Console.print passes the CSI 2J sequence through intact for both str and Text, so the boundary tests are genuine regression tests. All four helpers strip end-to-end through the real Renderer, while JSON mode emits the � escape and round-trips the payload byte-for-byte. Suite: 2907 passed, 1 failure that is the sandbox's test_non_fast_deps_uses_global_python, identical on main. ruff clean.
The gap. I stood up a hostile ComfyUI-shaped server (answers /history with 200, returns text/plain with raw escape bytes on /prompt) and ran the real CLI against it in pretty mode on this branch:
$ COMFY_OUTPUT=pretty comfy run --workflow wf.json --host 127.0.0.1 --port 8793
raw ESC bytes: 4 | CSI-2J: True | OSC-title: True
Error running workflow (HTTP 400)
prompt <clear-screen><set-title:PWNED><red>FAKE: install succeeded
╭─ error · client_error ────────────────────────────────╮ <- this part IS sanitized
The panel is clean. The line above it is not. comfy_cli/command/run/execution.py:203 prints the server's body via pprint under its own is_pretty() gate — two lines above the renderer.error(...) your boundary does cover, interpolating the same body_text. That is exactly the "identical hazard on the adjacent lines / false sense of coverage" your body argues the call-site approach would produce; the boundary approach lands there too, because Renderer is not the only path to the terminal.
Second, independent defect at the same line: a body of boom [/] unbalanced crashes the CLI with an unhandled rich.errors.MarkupError traceback — the precise failure your sanitize_markup docstring predicts. A remote server can hard-crash the client.
Both are pre-existing on main, not introduced here — I checked, main crashes identically. So this PR makes nothing worse. But it is scoped as the fix for server-supplied ANSI, and merging it as-is means the ticket closes with comfy run against a hostile host still executing escape sequences.
The fix is one line, using your own helper. I applied it locally and re-ran the same attack:
pprint(f"[bold red]Error running workflow (HTTP {e.status})\n{sanitize_markup(body_text)}[/bold red]")raw ESC bytes: 0 | CSI-2J: False | MarkupError: False
Error running workflow (HTTP 400)
boom [/] unbalanced <- renders literally, no crash
Sibling sites worth sweeping while you're in there (same shape — server-supplied text into a pretty-mode pprint/rprint, never touching Renderer):
command/run/__init__.py:962—w['message']from cloud warningscommand/run/preflight.py:111—w['field']/w['message'], field names derive fromobject_infocommand/models/search.py:606-620—row['name']/type/tags/source_urlfrom the cloud asset catalogcommand/run/__init__.py:471— the interpolated exception on lost connection
execution.py:193 and :544 are already safe — json.dumps escapes the escape byte — worth a comment saying so, so nobody "fixes" them later.
Everything else in the PR I agree with, including the judgment calls: extending to info/success is right, leaving Renderer.print alone is right, sanitize_value's str() coercion over a new TypeError is right, and consuming the tail of an unterminated OSC is the faithful reading. Re-request me once the pprint paths are covered and I will re-run the hostile-server suite against it.
…(BE-4794) The renderer boundary only covers text routed through `Renderer`. Command modules print under their own `is_pretty()` gates, and those strings never reach it — so the headline attack from the ticket (`comfy run --host/--port` against a hostile server) still cleared the screen and set the window title, as @bigcat88 proved on this branch. Two defects at each of those sites, both pre-existing on `main`: raw ANSI in the payload reaches the terminal, and Rich manufactures new escapes from markup it finds — with an unbalanced `[/]` raising `MarkupError` and hard-crashing the CLI from a remote string. Route the server-supplied values through the PR's own `sanitize_markup` across the `comfy run` and `comfy models` surfaces: the HTTP error body and `execution_error` payload, the submit `URLError` reason, `--verbose` node logs, preflight validation warnings, cloud/local output paths and warnings, the watcher's output URLs, and the asset-catalog rows (`Table.add_row` parses markup in `str` cells, same sink as the error panel's `_kv_table`). The two `json.dumps` sites are *not* already safe as the review suggested: `json.dumps` escapes `\x1b` but not `[`, so a node-error message containing `[/red]` still crashed the CLI. Verified, and they are sanitized too — the rendered blob is byte-identical to the original JSON. Also reword the module docstring, which claimed to be "the single sanitizing boundary": automatic coverage stops at `Renderer`, and a bare `rich.print` of remote text must sanitize explicitly. Saying otherwise is the "false sense of coverage" the PR body itself warns about. 15 regression tests drive the real call sites in pretty mode with a force-terminal console; each was verified to fail without its fix.
|
Fixed in 98665cc — you were right, and the reword alone would not have been enough. I widened coverage rather than just narrowing the claim, then did both.
One correction to your sweep list: So a hostile server crashes the client through the The docstring is reworded too — it now says automatic coverage stops at 15 regression tests in Two caveats I'd rather state than have you find:
Re-requesting review — the hostile-server suite should come back clean now. |
…BE-4794) Self-review turned up a third surface reachable by the identical attack. `comfy nodes ls/show/search/types/categories/path` take the same `--host/--port` pair as `comfy run` and render `object_info` straight from that host into markup sinks: node ids, categories, descriptions, display names, output/input types. Verified against a hostile `object_info`: `comfy nodes show --host <evil>` cleared the screen and set the window title, and a description of `boom [/] x` raised an unhandled `MarkupError`. Leaving this open would have closed BE-4794 with the same threat still working one command over — the exact objection raised on the run path. The intentional markup stays intentional: the `[dim]—[/dim]` empty-outputs fallback and the `[bold]`/`[cyan]` wrappers are ours, so only the joined server values inside them are escaped. Search/description cells truncate before escaping so the escapes stay balanced. 5 more regression tests drive the real commands against a hostile `object_info`; each verified to fail without the fix.
|
Follow-up to the above — my own adversarial pass found a third surface reachable by the identical attack, so I fixed it here rather than deferring it (1a5a94f).
Deferring that would have closed BE-4794 with the same threat still working one command over, which is exactly the objection you raised on the run path — so it seemed wrong to file it as a follow-up. Intentional markup is preserved: the 5 more regression tests (20 total in the new file), each verified failing without the fix. Full suite 2855 passed, 37 skipped; ruff clean on touched files. On the scope boundary I mentioned: I checked the rest of the repo's |
Textual conflict in `command/run/__init__.py` only. Main refactored the local `--wait` path: `jobs_state.write` became `_write_state`, and the pretty "Outputs:" block moved out of the `try` to after the `finally`, iterating `completed_payload["outputs"]` instead of `execution.outputs`. Took main's structure wholesale and re-applied the sanitize at the block's new home, so both intents survive: main's state-file semantics (report a `state_file` only when the terminal write landed) and this branch's escaping of server-chosen output filenames. Same for the `ws_disconnected` path — main's new `server_died` state-file block kept, with `sanitize_markup(e)` retained on the pretty line. Merged-tree verification: 3039 passed, 37 skipped; ruff clean on the touched files.
|
Heads-up on the red
Evidence it is inherited, not introduced here:
I deliberately did not make it green from this branch. The assertion is correct — it caught a real contract collision in the public error-code registry — and deleting or relaxing someone else's passing-by-design test to unblock my own PR would ship the breakage. Fixing it properly means collapsing the two entries into one and reconciling their descriptions/hints, which is a change to the public error-code surface and belongs in its own PR, not buried in an ANSI-sanitizing one. Filed as a separate follow-up. So: this branch is clean on its own merits. Once the Also note the branch now contains a merge of |
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
|
bigcat88
left a comment
There was a problem hiding this comment.
Fixed — approving. I re-ran the exact attack that broke this branch yesterday.
Before (my last review), same command, same hostile server:
raw ESC bytes: 4 | CSI-2J: True | OSC-title: True | MarkupError: crash
Now:
raw ESC bytes : 0
CSI-2J : False
OSC title-set : False
MarkupError : False
Error running workflow (HTTP 400)
prompt FAKE: install succeeded and [bold]markup[/bold] and [/]
Screen-clear gone, window-title rewrite gone, and the unbalanced [/] that used to raise an unhandled MarkupError now renders literally. The hostile server is the same rig: answers /history with 200, returns text/plain carrying raw ESC[2J, OSC title-set, and Rich markup.
All four sibling sites I listed are covered, plus one I'd missed:
run/execution.py:207—sanitize_markup(body_text), exactly the fixrun/__init__.py:473— lost-connection exception textrun/__init__.py:518, 963— cloud warnings / uploadsrun/preflight.py:113— bothfieldandmessagemodels/search.py:214, 215, 304— catalog-supplied names and subfolderscommand/nodes.py:287, 288, 362, 364— object_info-derived node ids, categories and display names. Good catch; I didn't flag that one and it's server-supplied text on a surface people run constantly.
The JSON contract is still intact, which is the part that would have been easy to break while fixing this. stdout is pure JSON (0 non-JSON lines), the raw escape bytes are preserved inside the payload so agents still parse exactly what the server sent, and no raw ESC byte appears on the wire. Strip at the terminal, never mutate the data — still correct.
On the suite: 3057 passed, 2 failed, and neither is yours.
test_non_fast_deps_uses_global_python— the usual sandbox artifact, fails on plainmainhere too.test_no_duplicate_codes—mainis currently broken, independently of this PR.error_codes.pyhasserver_diedregistered twice: #605 (BE-4750, "connection dropped while a foreground--waitjob was in flight") at line 366 and #604 (BE-4751, "local ComfyUI server became unreachable") at line 376. Both merged, git auto-merged them cleanly because they sit at different offsets in the list, and the combined tree fails. I confirmed it on a cleanmaincheckout ate6965d0witherror_codes.pyuntouched — nothing to do with this branch. Flagged separately; don't let it hold this up.
ruff check / ruff format --check clean over 261 files. 18 new cases in test_pretty_print_sanitize.py covering the direct-pprint sites.
Nice turnaround — the boundary claim in the description is now actually true.
ELI-5
When something goes wrong, the CLI prints the error message it got back from the ComfyUI server. If that server is a stranger's machine (
comfy run --host/--portcan point anywhere), it gets to choose the text — and terminals treat some text as commands, not characters. A message likejob <ESC>[2Jevil runningdoesn't say "clear my screen", it clears your screen. It can also rewrite your window title, or repaint lines that already scrolled past so the CLI appears to have said something it never said.This PR adds one small function that removes those invisible command characters, and calls it in the one place where human-facing text becomes terminal output. Machine-readable JSON output is untouched — it already escapes them safely, and stripping there would corrupt data that agents parse.
What changed
comfy_cli/output/sanitize.py— strips C0/C1 control characters and ANSI escape sequences (CSI, OSC, DCS, and the shorter two- and three-character forms). Tab and newline survive (legitimate layout in multi-line messages); carriage return does not (it lets a message repaint an already-printed line). Rich markup is deliberately left alone — call sites use[yellow]…[/yellow]for styling and escape untrusted text withrich.markup.escape.error_panel(the only pretty pathRenderer.errortakes) now sanitizescode,message,hint, and the stringifieddetailskeys and values.Renderer.warn/info/successsanitize theirmessageandhint.json.dumpsalready encodes ESC as a\uescape, so machine output was never exposed, and sanitizing there would silently mutate the payload agents read.Why here and not at the call sites: the pattern is repo-wide (every
renderer.error(...)interpolates{e}or a server-chosen id the same way), so fixing individual commands would leave the identical hazard on the adjacent lines and give a false sense of coverage. One boundary, applied once.This is the same class of defense the repo already applies to untrusted download filenames in
_sanitize_ext(comfy_cli/command/transfer.py) — that one guards the on-disk name, this one guards the rendered line.Verification
pytest tests/ --ignore=tests/e2e— 2813 passed, 18 skipped.ruff checkandruff format --check(pinned v0.15.15, matching.pre-commit-config.yaml) clean on every changed file. The 15 pre-existingUP038findings elsewhere in the repo come from a newer ruff and are untouched by this PR.tests/comfy_cli/output/test_sanitize.py(28 cases across CSI/OSC/DCS/APC, 8-bit C1 forms, unterminated sequences, idempotence, and the text that must survive), plus panel and renderer boundary tests. Both boundary tests are genuine regression tests — I confirmedrich.printpasses\x1bthrough unchanged, so they fail without this change.Judgment calls
Renderer.infoandsuccess. The ticket namederrorandwarn. Sanitizingwarnwhile leaving the adjacentinfounsanitized would reproduce the exact "false sense of coverage" the ticket argues against, and both take a plain string straight to the terminal. Zero call sites today, so no behavior risk.Renderer.printis deliberately NOT sanitized. It is the generic passthrough and accepts arbitrary Rich renderables, not just strings; sanitizing there would mean stringifying renderables. Callers with untrusted text should use the semantic helpers above.which_panel/auth_list_table/discover_panelare deliberately NOT sanitized. They render locally-sourced data (workspace paths, the local auth store, the CLI's own command help), not server-supplied text. Adding them would widen the diff without closing a reachable path.str()before sanitizing (sanitize_value, notsanitize). Their params are annotatedstr, butwarn/successpreviously interpolated via an f-string, which accepted anything. Keeping the coercion means a loose caller gets the old behavior instead of a newTypeError; there is a test for it.Capability-denial check
The change removes ANSI passthrough from four rendering helpers, so I checked empirically that nothing legitimately relies on it rather than assuming:
error_panelhas exactly one caller (Renderer.error);warn/info/successhave exactly one call site between them (comfy_cli/command/outdated.py:512, which already wraps its input inrich.markup.escape); all 242.error(...)call sites pass f-strings orstr(e); and no.pyfile incomfy_cli/contains a raw\x1b/\033/\e[literal. No product path emits intentional ANSI through these helpers, so nothing is denied.Closes BE-4794.