Skip to content

Security audit + CI hardening - #13

Merged
xuyanwen2012 merged 10 commits into
mainfrom
dev
Aug 19, 2026
Merged

Security audit + CI hardening#13
xuyanwen2012 merged 10 commits into
mainfrom
dev

Conversation

@xuyanwen2012

@xuyanwen2012 xuyanwen2012 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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 is
not 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::load bails on a shorter token, so a host whose
config.toml predates this will not start — systemd's
Restart=on-failure will just loop. This is not an API change and so is
absent 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:

awk -F'"' '/^token/ {print "token length:", length($2)}' config.toml

>= 32 is fine. Anything shorter needs a new token
(openssl rand -hex 32), and the Android client must be updated in the
same window
— it is the same bearer token, so a rotation without the
client returns 401 for everything. Hosts set up via just setup are
unaffected: it has always generated 64 hex characters.

Nothing else about the upgrade needs manual work. config.toml is otherwise
backward 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 additive ALTER TABLE ... DEFAULT 0.0 applied
automatically 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 returns metadata. It read an <output>.json
    sidecar that nothing in this codebase has ever written, so it was
    always null despite the contract promising otherwise.
  • GET /jobs/{id}'s error is now redacted like a 5xx body — filesystem
    paths become <path>, URLs <url>. Previously it handed the phone the
    server's comfy_url and local paths verbatim.

Status codes (1):

  • Uploads over 20 MiB return 413 payload_too_large instead of 400.

New validation, previously accepted (4):

  • timeout_seconds must be 1..=1800 — see the queue fix below.
  • label ≤ 1000 B, description ≤ 4000 B, input_name ≤ 255 B.
  • A multipart submit whose bytes don't match input_sha256 is now rejected
    even 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):

  • Default timeout_seconds 60 → 120.
  • Jobs no longer fail when ComfyUI is unreachable. They return to
    queued and resume when it comes back. Clients that render queued as
    in-progress will show a job as running for the duration of an outage.
  • ?wait= long-polling returns on the change itself and always within the
    requested window.

The queue-wedging bug

timeout_seconds was accepted from the client unvalidated. 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 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_job used to mark 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: 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
queued for 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 second
copy of its result in ComfyUI's output/. ComfyUI prunes neither and has
no delete endpoint, so purge_after_days only ever bounded this server's
copies. On the dev box: 23 MB under data/outputs beside 445 MB of the
same images
under ComfyUI/output, plus 116 MB of spent inputs.

New optional comfy_data_dir points the purge task at ComfyUI. 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 past the retention window. Enabling it removed 686
files 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_dir at ComfyUI's data directory (the one holding input/ and
output/), and the daily purge takes it from there.

Temp files left behind by failed writes

Raised in review against backup.rs and true there, but 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.

Nothing collects what leaks. 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 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 to
be created, and widening that filter would weaken what it is for — not
touching files in backups/ this server did not write.

paths::Staged now owns the temp path and removes it on drop unless
committed, 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:

before after
three queued jobs 17.4 s 9.0 s
first job after a ComfyUI restart 19.5 s 3.7 s
long-poll latency up to 750 ms ~0
  • Thumb/preview encoding left the worker's critical path; it cost ~2.5 s
    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.
  • A best-effort warm-up runs one throwaway generation at startup, moving
    the ~16 s of weight staging off the user's first tap.
  • Inputs are uploaded under a content-addressed name, so N prompts against
    one photo leave one file in ComfyUI's input/ instead of N.

Also

  • All direct dependencies upgraded to latest stable, including base64
    0.22→0.23 and tokio-tungstenite 0.29→0.30 across their major boundaries,
    with no source changes. Clears the yanked num-bigint from cargo audit.
  • Documentation that described things the code did not do: a ZUN_LOG_FORMAT
    env var that does not exist, a worker job span that was never opened, a
    /health endpoint claimed to probe ComfyUI, and a README pointing at a
    plan/PLAN.md that isn't in the repo.
  • ComfyClient::health() and its dedicated client were dead code; the
    reachability probe that replaced it has an actual caller (the warm-up).
  • security-audit.md has no open findings: SEC-005 (unbounded response
    bodies, 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 --locked132 tests, 0 failures (up from 113).
  • Every behavioural fix above has a regression test verified to fail
    against the pre-fix code.
  • End-to-end against a live ComfyUI, including v0.33.2 (up from 0.19.3,
    14 minors): upload, ws progress frames, /history, /view, atomic write,
    derived images, AVIF negotiation, ETag/304, and cancel→/interrupt.

…, 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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/backup.rs Outdated
Comment on lines +77 to +78
sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await?;
tokio::fs::rename(&tmp, &abs).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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());
    }

xuyanwen2012 and others added 7 commits August 18, 2026 23:05
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
@xuyanwen2012 xuyanwen2012 added this to the 2026-08-19-stable milestone Aug 19, 2026
xuyanwen2012 and others added 2 commits August 19, 2026 11:03
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
@xuyanwen2012
xuyanwen2012 merged commit cce7341 into main Aug 19, 2026
2 checks passed
@xuyanwen2012 xuyanwen2012 mentioned this pull request Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant