Goal: scan every customer-uploaded file for malware before staff download it. Today a customer file
travels straight from upload to the staff download endpoint with no inspection:
worker/routes/customer-orders.ts:38 (worker-mode PUT) or a presigned R2 PUT
(wrangler.production.jsonc:12) on the way in, and worker/routes/staff-orders.ts:78 streams the
object to staff with only a storageState === "available" check. This plan adds an asynchronous, out-of-band
ClamAV scan pipeline: R2 event notifications fan uploads into a Queue, a consumer streams the object through a
ClamAV container, and the result is recorded per file in D1 and enforced as a download gate.
Approach in one line: additive D1 migration for scan state → Queue consumer + ClamAV container → R2 event notifications + a cron sweep as the safety net → staff download blocked unless the file scanned clean. Decisions confirmed Aug 29, 2026: the scan engine is self-hosted ClamAV, and a file found malicious is deleted from R2 immediately — only its metadata, signature name, and audit trail remain.
External-service claims verified Aug 29, 2026 against official docs: Containers is GA and available on
the Workers Paid plan (docs last updated Aug 28, 2026) — the account is already on Workers Paid
(docs/CLOUDFLARE_ANNUAL_COST_GUIDE.md:15); R2 event notifications can push object-create
events (PutObject / CopyObject / CompleteMultipartUpload) into a Cloudflare Queue (docs last updated Apr 21, 2026).
docs/code-review.html (Aug 28, 2026) — praised the existing upload integrity checks
(worker/routes/customer-orders.ts:72, worker/uploads.ts:82) and recommended a
scheduled cleanup cron. This plan adds the first real scheduled handler (scan sweep) and
reuses the same infrastructure patterns the review endorsed (fail-closed gates, parameterized SQL, shared contracts).docs/email-system-explainer.html (planned Aug 29, 2026) — introduces Cloudflare Queues and the
email service. This plan rides on the same queue foundation: scan verdicts become order events, and
"file quarantined" notifications are a natural consumer of the email system once it lands.shared/order-contracts.ts:10)worker/routes/staff-orders.ts:82 gateThe shop machines download customer artwork (PDF, TIFF, PSD, INDD, DOCX…). Customer uploads come from
unauthenticated public endpoints (the code review already flags this), so a crafted file — an EPS/PDF with an
embedded exploit, a double-extension executable, or an archive bomb — lands on a shop workstation with zero
inspection. The files table (migrations/0000_initial_cloudflare_core.sql:10) tracks only
storage_state (pending / available); nothing records whether an object was
examined.
UPLOAD PATHS (unchanged)
worker mode (local/beta) r2-presigned mode (production)
PUT /api/uploads/:token ──────► PUT https://...r2.cloudflarestorage.com
worker/routes/customer-orders.ts:38 worker/uploads.ts:47 (aws4fetch presign)
│ │
└──────────────┬───────────────────┘
▼
R2 bucket (FILES binding)
│
object-create event notification (r2 → queue)
▼
Cloudflare Queue [file-scan-queue]
│
▼
Queue consumer (same or scanner worker)
│ stream object (R2 → worker → container)
▼
ClamAV container (clamd INSTREAM, no disk writes)
│ verdict: clean | infected | error
▼
D1 files.scan_state + order_events audit row
│
▼
Staff download gate: worker/routes/staff-orders.ts:78
streams ONLY when scan_state = 'clean'
Safety nets:
• Cron sweep (scheduled handler) re-enqueues files stuck
'pending_scan' > 5 min, or scan failures with attempts left
• Queue max-retries → DLQ → order event + staff alert email
worker/routes/customer-orders.ts:22-98
The initiate / upload / complete endpoints stay exactly as they are. Scanning runs out-of-band, so a slow ClamAV
engine (250 MB TIFF on a cold container) cannot time out a customer's PUT or the 10-minute presigned TTL
(worker/uploads.ts:8). The cost is a window where a file is "available" but not yet scanned — covered by
the download gate, which is the only place files leave the system.
Cloudflare Containers docs, verified Aug 29, 2026
Containers are GA and included with the Workers Paid plan the account already budgets for
(docs/CLOUDFLARE_ANNUAL_COST_GUIDE.md:15). A ScanContainer class
(@cloudflare/containers) runs the official ClamAV image with clamd + freshclam,
and the consumer streams object bytes over the container's fetch using clamd's INSTREAM protocol — no
disk writes, works for the full 250 MB max file size (clamd's default MaxScanSize of 100 MB
must be raised). No vendor, no per-file cost, customer files never leave Cloudflare. The known trade-off — ClamAV's
weaker coverage of targeted/zero-day malware versus commercial engines — is accepted as sufficient for a print shop's
inbound artwork.
worker/scanning/scanner.ts (new) · migrations/0000_initial_cloudflare_core.sql:10
When clamd returns a signature match, the consumer deletes the R2 object in the same step that records
scan_state = 'infected', so quarantined content does not sit in the bucket awaiting a manual decision.
Forensics survive in metadata only: scan_signature (e.g. Win.Test.EICAR_HDB-1) and the file
name/size stay in D1, and an internal order event records what was removed. Deletion is irreversible by
design — false positives cannot be recovered, so the implementation must:
order_events audit row with the signature name before deleting, and'infected' branch as also handling a missing object
(409 file_quarantined, never a raw 404 that invites a retry).wrangler commands: r2 bucket notification create + queues
An object-create notification rule on the bucket pushes every upload (worker-binding PUT and presigned
S3 PUT alike) into file-scan-queue — one mechanism covers both upload modes without touching the upload
code. A scheduled handler (the first in this codebase — worker/index.ts:17 currently exports
only fetch) sweeps every 5 minutes for files with scan_state = 'pending_scan' older than a
threshold, which also covers any notification gap and consumer outages. Deliverability of events from
Workers-binding writes should be verified in local dev during step 1; the sweep guarantees correctness either way.
storage_state valuemigrations/0000_initial_cloudflare_core.sql:17
storage_state means "does the object exist / is the upload complete" and is written by the upload path.
Overloading it with scan verdicts would couple two lifecycles. Migration 0001 adds
scan_state, scan_signature, scan_attempts, scan_completed_at to
files, defaulting existing rows to 'clean' (pre-scanning era files; the shop can re-scan
manually if ever needed — see Open Questions). Purely additive: no drops, renames, or data resets.
States: pending_scan → clean | infected | error, with
scan_attempts capping retries at 3 before error is final for that file.
worker/routes/staff-orders.ts:78-100
The download endpoint gains one check before FILES.get: scan_state must be
'clean'. 'pending_scan' returns 409 file_scan_pending ("still being scanned —
try again shortly"), 'infected' returns 409 file_quarantined ("removed by virus scanner" —
never leaks the signature name to the UI message, and never falls through to a raw 404 even though the object has been
deleted), 'error' returns 409 file_scan_unavailable. Same fail-closed style
as assertWorkerUploadEnabled (worker/uploads.ts:76). Nothing in the system serves file
bytes except this endpoint, so one gate covers everything.
shared/order-contracts.ts:89-95 · worker/orders/commands.ts:245
Extend OrderEvent["type"] with file_scan_failed and file_quarantined and
append rows through the existing appendEvent path. The staff order detail view shows a per-file badge
(OrderFile gains scanState): "scanning…", "clean", "quarantined", "scan failed". If the
email plan lands first, a quarantined file also emails the shop owner.
clamd running with freshclam on a
loop; clamd.conf raises MaxScanSize/MaxFileSize to 300 MB and
StreamMaxLength to 300 MB (files cap at 250 MB per shared/order-contracts.ts:12).standard-1 (0.5 vCPU / 4 GiB disk 8 GB) minimum — ClamAV's
signature DB needs ~2 GiB RAM. sleepAfter ~10 min so idle instances stop billing. Instance
types and account limits verified Aug 29, 2026 (image size limit = instance disk size; 50 GB total image
storage per account — ClamAV's image is large, keep only current versions pruned).container.fetch which the @cloudflare/containers client handles
transparently. Queue message retention absorbs the delay.object.key, looks up
the matching files row by object_key, and ignores keys with no row (defensive).scan_state = 'clean', scan_completed_at set, download unlocked.scan_state = 'infected' + scan_signature recorded
(internal only), order event appended with the signature name, then the R2 object is deleted
(see the deletion decision above). Download permanently blocked.scan_attempts + 1; message retried by the queue until
max_retries, then cron sweep retries up to 3 total; beyond that scan_state = 'error' +
event + alert email. Downloads stay blocked.scan_state = 'infected' whose object key still exists in R2 as leftover — it deletes the object
idempotently. This makes the deletion order (commit first, delete second) safe.pending_scan and staff downloads gate closed. Staff UI shows "scanning…"
so the failure mode is visible, not silent.observability with head sampling
(wrangler.production.jsonc:40); scan latency is derivable from
scan_completed_at vs object event time in order_events. Add a
GET /api/staff/scan/summary (counts by state) as a cheap health check — optional step 7.| Step | Action | Notes |
|---|---|---|
| 1 | Local: migration + scan state plumbing + gated download, scanner stubbed | npm run db:setup applies 0001 locally; wrangler dev supports running containers locally; tests use a stub verdict so no container is needed in CI. |
| 2 | Local: real ClamAV container + queue consumer end-to-end | Create file-scan-queue; verify worker-binding PUTs emit object-create events; confirm the cron sweep rescues anything missed. |
| 3 | Beta/preview: npm run deploy:preview |
Create the queue + R2 notification rule against the preview bucket; set triggers.crons; observe one real upload through the full loop. |
| 4 | Production migration: npm run db:migrate:production |
Additive columns default 'clean' / 'pending_scan' for new rows — no data changes to existing rows beyond the backfill decision. |
| 5 | Production deploy: npm run deploy:production |
Production runs the generated bundle (wrangler.production.jsonc:4, package.json:16) — the generator must carry the container class, queue consumer, and cron config (see checklist). |
| 6 | Create production queue + r2 bucket notification create copy-shop-files-production --event-type object-create --queue file-scan-queue |
One-time account/bucket configuration, ordered after the consumer exists so messages aren't lost. |
migrations/0001_file_scan_state.sql (new) — additive
scan_state, scan_signature, scan_attempts, scan_completed_at;
backfill existing rows 'clean'; index on (scan_state, upload/created) for the sweep.db/schema.ts — mirror the new columns (drizzle
npm run db:generate produces 0001).shared/order-contracts.ts — FileScanState union,
OrderFile.scanState, extend OrderEvent["type"].worker/scanning/container.ts (new) —
ScanContainer extends Container (ClamAV, standard-1, sleepAfter).worker/scanning/scanner.ts (new) — queue consumer: resolve
file row, stream R2 → clamd INSTREAM, write verdict, append audit event before deleting the object on
infection; plus scheduled sweep logic (retries + idempotent infected-object cleanup).worker/routes/staff-orders.ts:82 — extend the gate:
stream only when scan_state === 'clean'; distinct error codes per state; 'infected'
must short-circuit before FILES.get since the object no longer exists.wrangler.jsonc / wrangler.production.jsonc —
containers, Durable Object migration for the container class, queue producer/consumer bindings,
triggers.crons (every 5 min).scripts/generate-hosted-worker.mjs — carry the container
class export, queue consumer, and scheduled handler into the generated production bundle (it already inlines the
worker entry; this is the riskiest existing-code touchpoint).src/app/staff/* — per-file scan badge in order detail; blocked
download messaging maps to the new error codes.tests/ — worker tests: gate refuses
pending_scan/infected/error (with the R2 object absent for infected),
sweep retries and caps attempts, sweep deletes leftover infected objects, clean unlocks;
scanner verdict logic tested with a stub clamd client so CI needs no container. Existing e2e happy path keeps
passing (seed files default to 'clean').main is behind origin/main by 1
commit (fast-forwardable). Pull before starting any of this work.'clean' — see Open Questions). Deletion of infected objects is a deliberate, user-confirmed policy:
it applies only to files a ClamAV signature flags after this feature ships, never to existing objects.docs/code-review.html and
docs/email-system-explainer.html — it must never be committed to git.'clean' (proposed) or
'pending_scan' so the sweep scans everything historical once (beta volume is small; production has
real customer data)? Note: with deletion-on-infection now decided, historical re-scans could remove old files —
an argument for simply marking existing rows 'clean'.'clean', or is the download gate alone acceptable (proposed)?docs/CLOUDFLARE_ANNUAL_COST_GUIDE.md; a standard-1 instance idling ~10 min after
each upload burst. Acceptable? (Volume here is low — tens of uploads/day.)