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.
- Uploading
- Scanning
- 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
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:stableimport { 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 checksclamd 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.