Security audit + CI hardening - #13
Conversation
…, weak token check, and a 413-vs-400 bug Ran a full security audit (specs/001-ci-cd-security-hardening/security-audit.md) and CI/CD gap evaluation across auth, input validation, dependencies, file/path handling, and the outbound ComfyUI boundary. Fixed the cheap/high-value findings: - Raise the minimum bearer token length from 16 to 32 chars - Add a 10s timeout around the ComfyUI WebSocket connect+upgrade, so a wedged backend can't stall the single-job worker queue forever - Make the daily DB backup snapshot crash-safe via temp-sibling + rename (paths::tmp_sibling made pub for reuse), instead of writing straight to the final dated filename - Replace an unreachable!() in resolve_input with a graceful 500 - Add regression tests for purge/backup (previously uncovered) and for the upload size limit / malformed-image-bytes edge cases Writing the upload-size-limit test surfaced a real bug (SEC-013): every MultipartError, including a body-too-large rejection that axum reports as 413, was being collapsed into a 400 by AppError's From impl. Fixed by preserving the 413 via a new AppError::PayloadTooLarge, and updated docs/API_CONTRACT.md's error-code list accordingly. Also added `cargo audit` to CI (.github/workflows/rust.yml) per the evaluation's one recommendation, installed via taiki-e/install-action so no extra workflow permissions are needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive security hardening and CI/CD evaluation for the zun-rust-server. Key changes include increasing the minimum token length to 32 characters, introducing a WebSocket connection timeout to prevent worker stalls, ensuring atomic database backup writes via temporary files, mapping multipart payload size limit errors to a 413 status code, and adding extensive integration tests for backup, purge, and submit functionalities. Feedback on the changes suggests cleaning up the temporary database backup file if the VACUUM INTO or rename operations fail to prevent file leaks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await?; | ||
| tokio::fs::rename(&tmp, &abs).await?; |
There was a problem hiding this comment.
If VACUUM INTO or tokio::fs::rename fails, the temporary file created by tmp_sibling will be left on disk. To prevent leaking temporary files on failure, we should clean up the temporary file if either operation fails.
if let Err(e) = sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(e.into());
}
if let Err(e) = tokio::fs::rename(&tmp, &abs).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(e.into());
}Every direct dependency moves to its latest stable release on crates.io. Two cross the major boundary — base64 0.22 -> 0.23 and tokio-tungstenite 0.29 -> 0.30 — and neither needed a source change; the rest are patch or minor bumps (tokio 1.53.1, uuid 1.24.1, thiserror 2.0.20, serde 1.0.229, serde_json 1.0.151, anyhow 1.0.104, toml 1.1.4, tokio-util 0.7.19, include_dir 0.7.4, futures-util 0.3.34, http-body-util 0.1.5). base64 0.22 stays in the lockfile as a transitive dep of sqlx-core; crypto-common and matchit are each held one release back by sha2 0.11 and axum 0.8 respectively. Clears half of SEC-001: `cargo audit` no longer reports the yanked num-bigint 0.4.7. `paste` unmaintained remains, still 0 CVEs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
Twelve issues found by reading the tree end to end. The one that could
actually take the server down:
`timeout_seconds` was accepted from the client unvalidated, so a prompt
stored with a negative value reached the worker as `-1i64 as u64` ==
u64::MAX — a timeout that never fires, on a worker that runs exactly one
job at a time. That wedged the whole queue until the process was
restarted. Now range-checked (1..=1800) on write and clamped again on
read, so rows written before the check still produce a timeout that
fires.
Also fixed:
- worker: a job hitting its timeout left ComfyUI still executing, holding
the GPU with nobody collecting its output, so the next job queued
invisibly behind it. It now calls /interrupt, same as cancel does.
- comfy: response bodies were buffered without any cap (SEC-005). All
four call sites go through read_capped/json_capped — 8 MiB for JSON,
128 MiB for /view — which reject an oversized content-length up front
and abort mid-stream otherwise.
- handlers: the uploaded bytes were only hash-checked on the write path,
so a cache hit served the stored file and silently ignored a mismatched
upload. The check moved ahead of the cache lookup, which is what
API_CONTRACT already promised.
- handlers: `GET /jobs/{id}?wait=` slept a full interval past its own
deadline and skipped the poll that would have landed on it. It now
clamps the nap to the deadline.
- handlers: the `metadata` key read a `<output>.json` sidecar that
nothing in this codebase has ever written, so it was permanently null.
Reader, response key, row column and contract clause all removed.
- comfy: ComfyClient::health() and its dedicated client were never
called; lib.rs claimed /health probed ComfyUI reachability. Both gone.
- derived_images/purge/images: the "AVIF sits next to the JPEG" rule had
three separate implementations, and purge leaking .avif files was
already a bug once. One avif_sibling() now defines it.
- paths/derived_images: render_only carried its own temp+rename copy of
the atomic-write invariant (SEC-007). Folded into
paths::atomic_write_blocking.
- inputs: the stored content_type was echoed straight back as the
response header; now filtered through a jpeg/png allowlist.
- custom_prompts/handlers: label, description and input_name had no
length bound while text had one.
- logging: documented a ZUN_LOG_FORMAT env var that does not exist and a
worker `job` span that was never opened; the LogFormat::Auto match arm
needed an unreachable!() that a refactor could have turned into a
process abort. Doc corrected, arm designed away.
security-audit.md now has no open findings. 123 tests pass, up from 113 —
the timeout wedge, the interrupt-on-timeout, the long-poll window and the
cache-hit hash check each got a regression test verified to fail against
the pre-fix code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
Four changes to the server<->ComfyUI boundary, all measured against a live
ComfyUI on an RTX 4070 Ti Super rather than reasoned about. The request
path itself turned out to need nothing: input read, upload, ws connect and
submit together take 20 ms against 3.3 s of GPU time, so it is untouched.
Leaked storage. Every job uploads its source image into ComfyUI's input/
and leaves a second copy of its result in ComfyUI's output/. ComfyUI
prunes neither and exposes no delete endpoint, so `purge_after_days` only
ever bounded this server's own copies — on the dev box that meant 23 MB
under data/outputs beside 445 MB of the same images under ComfyUI/output,
plus 116 MB of spent inputs. A new optional `comfy_data_dir` points the
purge task at ComfyUI so it cleans up after itself. The sweep is
deliberately narrow, because it deletes from a directory this server does
not own: opt-in, never recursive, and limited to files that are both
`zun_`-prefixed and older than the same retention window. Enabling it on
the dev box removed 686 files and 538 MB while leaving every non-`zun_`
file (example.png, the bumpcheck/flux2_klein_t2i outputs) untouched.
Worker throughput. Thumb and preview encoding ran inside the worker's
critical path, so the GPU sat idle for ~2.5 s after each ~3.3 s job and
the next queued job waited on JPEG/AVIF encoding. It now runs detached.
Nothing depends on it finishing — the image handlers already lazily
generate a missing rendition — so a failure or a shutdown mid-encode costs
one slow first view. Measured on three queued jobs: 17.4 s -> 9.0 s.
Upload naming. Inputs were uploaded as `zun_{job_id}`, so running five
prompts against one photo left five identical copies in ComfyUI's input/
forever. They are now named by the input's content hash, which
`overwrite=true` makes idempotent: three jobs sharing an input now add one
file instead of three. The output prefix stays job-scoped, since
`primary_output` matches on it.
Default timeout 60s -> 120s. Measured first-job cost after a ComfyUI
restart: ~20 s with the GPU free, ~105 s with something else holding most
of the VRAM. 60 s covered the warm case (~3 s) and quietly failed the
contended cold start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
`GET /jobs/{id}` hands `error` straight to the phone, but the worker stored
`format!("{e:#}")` verbatim, so a failed job leaked exactly what AppError
takes care to strip from a 5xx body. Seen on a live run:
"error sending request for url (http://127.0.0.1:8188/upload/image):
client error (Connect): tcp connect error: Connection refused"
The redaction was only ever wired into one of the two paths an error string
can reach the client through. `mark_failed` is the sole writer of
`error_message`, so redacting there covers it; the operator loses nothing,
since the `job.failed` audit line already carries the full unredacted chain
for exactly this purpose.
Regression test: tests/worker.rs::stored_job_error_is_redacted_like_a_5xx_body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
Three changes at the server<->ComfyUI seam, each verified against a live backend rather than only in tests. A ComfyUI restart used to take the whole queue with it. Any error from process_job marked the job `failed`, so a backend that was merely absent had every queued job claimed, burned through its retries, and buried ~1.5 s apart — with no endpoint to bring them back. "The backend isn't there" is not the job's fault: comfy::Unreachable now marks that case (the ws path has no typed error to inspect, so connect failures and handshake timeouts carry it explicitly), and the worker requeues and backs off instead of failing. Verified live: with ComfyUI killed, three submitted jobs sat `queued` for 25 s with zero failures, then drained on their own once it came back — no operator action. Startup warm-up. The first job after a ComfyUI restart pays ~20 s to stage ~4 GB of weights, against ~3 s once resident, and that lands on the user's first tap. A best-effort task now waits for the backend (up to 5 min) and runs one throwaway generation. It uses the real workflow on purpose — a partial one would not make the sampler pull weights into VRAM. Measured on a cold boot of both processes: warm-up took 6.9 s, and the first real job afterwards took 3.69 s instead of 19.5 s. Long-poll latency. `?wait=` re-read the row every 750 ms, so on a ~3 s job reporting four progress steps it could sit on a change for a quarter of the job's life. Writers now bump a watch channel and the wait blocks on it. The 1 s fallback tick is deliberate: without it the long-poll's correctness would depend on every present and future writer of status/progress remembering to signal, and the existing get_job_wait_returns_early_on_status_change test caught exactly that regression when the fallback was absent. Measured live: successive waits returned in 0.03 s / 0.60 s / 0.61 s, tracking the real sampler cadence rather than quantising to the old interval. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
Caught by the zun-android-app session while reconciling this release's API surface: the client sends `label` from a free-text field with no client-side limit, and the 200-byte cap added a few commits ago binds at about 66 Chinese characters. The caps exist to keep bulk out of the DB and the audit log, not to tell a user how long a label may be — but a byte count costs three times as much per character in CJK as in Latin, and this server's user writes Chinese, so 200 B was silently acting as a product constraint on an ordinary label. The unit stays bytes, matching `text` and the documented contract; only the values move (label 200 -> 1000, description 1000 -> 4000), far enough that neither can bind on human-typed input in any script while still bounding abuse, and both still well under `text`'s 8 KiB. Regression test: tests/prompts.rs::accepts_a_long_chinese_label, which fails against the previous cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
The homelabhq session, reviewing this release against the actual deployment host, pointed out what the warm-up costs there: its Arc B580 has 11.93 GiB and is shared with Jellyfin's VAAPI transcoding and an Immich ML satellite. Warming pins ~7.5 GiB of FLUX2 klein weights from boot rather than loading them on the first job and releasing them between jobs, leaving those two services about 4 GiB. So `warmup_on_start` is now a config flag, and it defaults to off. That default is chosen on which mistake is cheaper to make: forgetting to turn it off on a shared card costs other services the GPU, while forgetting to turn it on costs one slow job after a restart (~20 s instead of ~3 s). Both machines this has run on share their GPU with something else — the dev box was running llama-server on the same card while these numbers were measured — so a shared card is the case to default for, not the exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
A PR review flagged that `backup::snapshot_once` leaves its temp file behind when `VACUUM INTO` or the rename fails. It does, and the same hole was in `paths::atomic_write` and `atomic_copy` — the two that run on every job output and every input upload, against once a day for the backup. The reviewer found the least frequent instance of the pattern. Nothing collects what leaks. `backup::prune_old` skips anything whose extension isn't `db`, and a `jobs-2026-08-19.db.tmp.a1b2c3d4` name reports its extension as the random suffix; `purge` only deletes paths recorded in the DB, which a temp file never reaches. The likeliest reason for either step to fail is a full disk, so the leak arrives exactly when the space is needed, and the next day's attempt leaves another. `prune_old`'s filter is deliberately left alone: the fix is for the strays not to be created, not to add a sweeper, and widening that filter would weaken what it is actually for — not touching files in `backups/` that this server did not write. Rather than repeat the cleanup at three call sites, `paths::Staged` owns the temp path and removes it on drop unless committed, which is also the half of SEC-007 that was missed: unifying the implementation without checking that the implementations being unified behaved the same. The blocking variant added there already cleaned up; the two async ones it was meant to consolidate with did not. Regression tests do cover the failure path, contrary to my earlier estimate that it was impractical to trigger: renaming onto a non-empty directory fails while the staging write succeeds, which is exactly the window. All three are verified to fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
Tracks a migration the repository owner performed across the workspace: `.specify/` out, `openspec/` in. Committed on its own rather than folded into the release, since it is tooling and not part of v3.1.0. `memory/constitution.md` moves unchanged — git records it as a pure rename. This repo's constitution governs this repo (single-user simplicity, surgical changes, quality gates, the security boundary, compile-time over runtime configuration, no inspection of sensitive job content) and is deliberately not reconciled with the other copies in the workspace; they each govern their own domain, and the org constitution's own Principle V is sub-repo autonomy. The 16 speckit scripts, templates and manifests are dropped rather than ported: their function is covered by the `openspec-*` skills. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013JeBUDKFawAWa1S9zvPGy1
Stability and security release: bug fixes, hardening and dead-code removal,
with no new features and no breaking API changes. One commit
(
Move speckit scaffolding to openspec) is tooling that rode along and isnot part of the release itself.
Before upgrading a running deployment
The minimum bearer token length went from 16 to 32 characters (security
audit SEC-002).
Config::loadbails on a shorter token, so a host whoseconfig.tomlpredates this will not start — systemd'sRestart=on-failurewill just loop. This is not an API change and so isabsent from the compatibility list below, but it is the one change that can
take a deployment down.
Check on the host before upgrading, without printing the token:
>= 32is fine. Anything shorter needs a new token(
openssl rand -hex 32), and the Android client must be updated in thesame window — it is the same bearer token, so a rotation without the
client returns 401 for everything. Hosts set up via
just setupareunaffected: it has always generated 64 hex characters.
Nothing else about the upgrade needs manual work.
config.tomlis otherwisebackward compatible (the three fields added since v2.0.0 all have defaults,
and unknown keys are ignored), and the one new migration
(
0004_add_progress) is an additiveALTER TABLE ... DEFAULT 0.0appliedautomatically at startup. v2.0.0 upgrades straight to this release with no
intermediate step.
For API consumers (zun-android-app)
No endpoint was added, removed, renamed, or changed signature. Verified
against the client: nothing here requires a coordinated client change.
Response shape (2):
GET /jobs/{id}no longer returnsmetadata. It read an<output>.jsonsidecar that nothing in this codebase has ever written, so it was
always
nulldespite the contract promising otherwise.GET /jobs/{id}'serroris now redacted like a 5xx body — filesystempaths become
<path>, URLs<url>. Previously it handed the phone theserver's
comfy_urland local paths verbatim.Status codes (1):
payload_too_largeinstead of 400.New validation, previously accepted (4):
timeout_secondsmust be1..=1800— see the queue fix below.label≤ 1000 B,description≤ 4000 B,input_name≤ 255 B.input_sha256is now rejectedeven when that hash is already cached. Previously the cached file was
used and the uploaded bytes silently ignored, so a client sending
(hash of A, bytes of B) got a 202 and a job rendered from A — despite the
contract already stating "Server verifies bytes match".
Behaviour (3):
timeout_seconds60 → 120.queuedand resume when it comes back. Clients that renderqueuedasin-progress will show a job as running for the duration of an outage.
?wait=long-polling returns on the change itself and always within therequested window.
The queue-wedging bug
timeout_secondswas accepted from the client unvalidated. A prompt storedwith a negative value reached the worker as
-1i64 as u64==u64::MAX—a timeout that never fires, on a worker that runs exactly one job at a
time. That wedged the entire queue until the process was restarted. Now
range-checked on write and clamped on read, so rows written before the
check still produce a timeout that fires.
Surviving a ComfyUI restart
Any error from
process_jobused to mark the jobfailed, so a backendthat was merely absent had every queued job claimed, burned through its
retries and buried ~1.5 s apart, with no endpoint to bring them back.
"The backend isn't there" is not the job's fault: connect failures and ws
handshake timeouts are now typed as unreachable, and the worker requeues
and backs off. Verified live — with ComfyUI killed, three jobs sat
queuedfor 25 s with zero failures, then drained on their own.Storage that was never reclaimed
Every job uploads its input into ComfyUI's
input/and leaves a secondcopy of its result in ComfyUI's
output/. ComfyUI prunes neither and hasno delete endpoint, so
purge_after_daysonly ever bounded this server'scopies. On the dev box: 23 MB under
data/outputsbeside 445 MB of thesame images under
ComfyUI/output, plus 116 MB of spent inputs.New optional
comfy_data_dirpoints the purge task at ComfyUI. The sweepis deliberately narrow because it deletes from a directory this server does
not own: opt-in, never recursive, and limited to files that are both
zun_-prefixed and past the retention window. Enabling it removed 686files and 538 MB while leaving every non-
zun_file untouched.This is opt-in and defaults to off — upgrading does not reclaim anything
by itself. Reclaiming the backlog is a deployment action: point
comfy_data_dirat ComfyUI's data directory (the one holdinginput/andoutput/), and the daily purge takes it from there.Temp files left behind by failed writes
Raised in review against
backup.rsand true there, but the same hole wasin
paths::atomic_writeandatomic_copy— the two that run on every joboutput and every input upload, against once a day for the backup.
Nothing collects what leaks.
prune_oldskips anything whose extensionisn't
db, and ajobs-2026-08-19.db.tmp.a1b2c3d4name reports itsextension as the random suffix;
purgeonly deletes paths recorded in theDB, which a temp file never reaches. The likeliest reason to fail is a full
disk, so the leak arrives exactly when the space is needed.
prune_old's filter is left alone on purpose: the fix is for strays not tobe created, and widening that filter would weaken what it is for — not
touching files in
backups/this server did not write.paths::Stagednow owns the temp path and removes it on drop unlesscommitted, so the cleanup is not three call sites remembering. That is also
the half of SEC-007 that was missed: the implementations were unified
without checking that they behaved the same.
All three failure paths have regression tests, verified to fail against the
previous code. (An earlier note in this discussion said the failure path was
impractical to trigger — that was wrong: renaming onto a non-empty directory
fails while the staging write succeeds.)
Performance
Measured on an RTX 4070 Ti Super, FLUX2 klein, release build:
against ~3.3 s of GPU time, so the card idled for nearly half of each
job. Nothing depends on it finishing — the image handlers already
generate a missing rendition lazily.
the ~16 s of weight staging off the user's first tap.
one photo leave one file in ComfyUI's
input/instead of N.Also
0.22→0.23 and tokio-tungstenite 0.29→0.30 across their major boundaries,
with no source changes. Clears the yanked
num-bigintfromcargo audit.ZUN_LOG_FORMATenv var that does not exist, a worker
jobspan that was never opened, a/healthendpoint claimed to probe ComfyUI, and aREADMEpointing at aplan/PLAN.mdthat isn't in the repo.ComfyClient::health()and its dedicated client were dead code; thereachability probe that replaced it has an actual caller (the warm-up).
security-audit.mdhas no open findings: SEC-005 (unbounded responsebodies, now capped at 8 MiB JSON / 128 MiB images) and SEC-007 (duplicated
atomic-write implementation) both closed.
Verification
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test --locked— 132 tests, 0 failures (up from 113).against the pre-fix code.
14 minors): upload, ws progress frames,
/history,/view, atomic write,derived images, AVIF negotiation, ETag/304, and cancel→
/interrupt.