Virus scanning

Scan every upload on your server with ClamAV, VirusTotal or your own scanner. Infected files are deleted and shown as rejected.

A browser can't scan files for malware, so UploadCN never pretends to. Scanning happens in your upload route, after the file is stored and before the upload completes. The UI only reflects the verdict.

  1. Uploading
  2. Scanning
  3. Success
  • ScanningRejected, malware found: the file is deleted

Add it to your route

Pick a scanner

import { clamavScanner } from "@uploadcn/server/scan"

const scanner = clamavScanner({ host: process.env.CLAMAV_HOST })

Pass it to createUploadRoute

app/api/upload/route.ts
export const { POST } = createUploadRoute({
  storage,
  scan: { scanner },
})

That's it on the client

Nothing changes in the browser. A clean file completes as usual; an infected one is deleted and its item becomes rejected with "This file contains malware and was removed". Rejected uploads are final: the engine doesn't retry them.

The upload route from npx shadcn@latest add @uploadcn/upload-route already does this when CLAMAV_HOST or VIRUSTOTAL_API_KEY is set.

Scanners

Free, open source and self-hosted. Files stream to clamd over its INSTREAM protocol and are never written to disk on your app server.

docker run -d -p 3310:3310 clamav/clamav:stable
import { clamavScanner } from "@uploadcn/server/scan"

const scanner = clamavScanner({
  host: process.env.CLAMAV_HOST, // or socketPath: "/run/clamav/clamd.sock"
  port: 3310,
  timeout: 60_000,
})

await scanner.ping() // true when clamd answers, for health checks

clamd refuses streams over its StreamMaxLength (25 MB by default). Raise it in clamd.conf to match your largest upload. ClamAV opens a TCP socket, so the route must run on Node.js, not an edge runtime.

Options

Prop

Type

onUploadComplete receives the verdict as scan, and the client's item.result.scan says { status: "clean", scanner: "clamav" }. Threat names stay on the server.

createUploadRoute({
  storage,
  scan: {
    scanner,
    onResult: async ({ key, result, auth }) => {
      if (result.status === "infected") await alertSecurityTeam({ key, user: auth, threats: result.threats })
    },
  },
  async onUploadComplete({ key, scan }) {
    await db.files.create({ key, scannedBy: scan?.scanner })
  },
})

Storage requirements

Scanning reads the stored object back, so the storage must implement getObject: s3Storage, r2Storage and createFileSystemStorage do. createMemoryStorage keeps no bytes and can't be scanned.

Keep unscanned files private

With presigned uploads the bytes reach your bucket before the scan. Keep the bucket private (no publicUrl) so nobody can download a file until complete returns, or upload to a quarantine prefix and copy clean files to their final location in onUploadComplete.

Large files and background scans

Scanning inline holds the complete request open. For multi-gigabyte files, scan in the background instead: skip scan, start a job in onUploadComplete, and drive the item from your job's result with process or uploader.update. See Processing & scanning.

On AWS, GuardDuty Malware Protection for S3 scans new objects and tags them with GuardDutyMalwareScanStatus; poll that tag from process, or wrap it in createScanner.

Test it

The EICAR test file is harmless, and every scanner reports it as infected. Upload it to check the whole flow end to end.

On this page