Inbound File Scanning — Implementation Plan

Copy Shop Order System · Cloudflare Containers (ClamAV) + R2 Event Notifications + Queues · Planned Aug 29, 2026 · Private review document — do not commit

Summary

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).

Relationship to Prior Documents

Current State and Threat Model

Files per order
Up to 20 · 250 MB each (shared/order-contracts.ts:10)
Upload paths
Worker PUT (local/beta) + presigned R2 PUT (production)
Scan coverage today
None — download is size/state check only
Enforcement point
worker/routes/staff-orders.ts:82 gate
High

The 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.

Architecture

                  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

Design Decisions

Medium Scan asynchronously — never block the customer upload path

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.

Confirmed Scan engine: ClamAV in a Cloudflare Container (decided)

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.

High Infected files are deleted from R2 immediately (decided)

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:

Good Trigger with R2 event notifications, back it with a cron sweep

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.

Medium Scan state is a new column, not a new storage_state value

migrations/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.

High Enforce at the staff download gate, fail closed

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.

Low Audit trail and staff UX via order events

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.

Scan Pipeline Details

Container + consumer protocol

Verdict handling

Failure Handling and Observability

Deployment Sequence

StepActionNotes
1Local: 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.
2Local: 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.
3Beta/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.
4Production 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.
5Production 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).
6Create 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.

Implementation Checklist

Branch & Safety Notes

Open Questions

  1. Backfill of existing files: mark pre-scanning rows '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'.
  2. Production order gating: should an order with unscanned files stay out of the staff "New" queue until all files are 'clean', or is the download gate alone acceptable (proposed)?
  3. Consumer placement: queue consumer + container in the main worker (simpler, but the generated production bundle must grow a container class) vs a dedicated scanner worker (isolates the risky generator change, adds a deployable unit)? Proposed: main worker, generator updated carefully.
  4. Cost authorization: Containers usage is billed beyond the $5/mo Workers Paid base in 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.)
  5. False-positive recovery path: with deletion irreversible, confirm the shop is comfortable asking the customer to re-send a file in the rare false-positive case (the audit event preserves the file name so the request is specific).