Malware Scanning for Uploads (#3019)
Every uploaded file is scanned by Amazon GuardDuty Malware Protection for S3 before it reaches primary storage. Uploads are presigned to a native AWS S3 staging bucket (GuardDuty can only scan S3, not R2); GuardDuty scans on arrival; a finding-handler Lambda copies clean files into R2 and fails infected/unscannable ones.
client β presigned PUT β S3 staging bucket (file status: 'scanning') β GuardDuty scans on upload (seconds) EventBridge "GuardDuty Malware Protection Object Scan Result" β β malware-scan-finding Lambda NO_THREATS_FOUND β copy stagingβR2, delete staging, status 'uploaded' THREATS_FOUND β delete staging, status 'failed' (MALWARE_DETECTED) UNSUPPORTED/β¦ β delete staging, status 'failed' (SCAN_UNAVAILABLE) β fail closedComponents
| Piece | Where |
|---|---|
scanning file status + staging_key / scan_started_at columns | migration 302_file_scanning_status.sql |
| Presign-to-staging + from-url staging + 5-min scan-timeout sweep | workers/api/src/routes/files.ts (gated by isMalwareScanEnabled()) |
| Scan decision logic | workers/api/src/services/malware-scan.ts |
| Finding-handler Lambda | workers/api/src/lambda-scan-finding.ts |
| Staging bucket, GuardDuty plan, IAM role, EventBridge rule, finding Lambda | infra/cdk/lib/stacks/api-stack.ts |
Feature flag
The path is ENABLED in production as of 2026-09-14 (UPLOAD_MALWARE_SCAN_ENABLED: 'true'
in api-stack.ts). /upload presigns to the S3 staging bucket, GuardDuty scans,
and the finding handler admits clean files to R2. isMalwareScanEnabled() requires
both the flag === 'true' and UPLOAD_STAGING_BUCKET set β a flag with no
bucket stays off rather than presigning to a nonexistent bucket. To roll back, set
the flag to 'false' (see below); the path reverts to presigning straight to R2.
Enablement (one-time, gated)
- Enable a GuardDuty detector in the API account/region (
696081601609, the APIβs region) if one is not already active. Malware Protection for S3 findings require an active detector. (Console: GuardDuty β Enable; oraws guardduty create-detector --enable.) - Deploy the stack.
cdk deploycreates the staging bucket, theCfnMalwareProtectionPlan, the IAM role GuardDuty assumes, the finding Lambda, and the EventBridge rule β all while the flag is still off. - Verify the plan is ACTIVE. GuardDuty β Malware Protection for S3 β the
staging bucket should show status Active (watch for the
GuardDuty Malware Protection Resource Status Warning/ErrorEventBridge events if it does not β usually an IAM role permission gap). - Smoke-test dark. With the flag still off, PUT a benign file and the
EICAR test file into the staging bucket manually
and confirm the finding Lambda logs
admitted/blockedrespectively. - Flip the flag. Set
UPLOAD_MALWARE_SCAN_ENABLED=trueon theβ¦-apiLambda (env var; no code change) and redeploy or update the config. New uploads now route through staging.
Verification (after enabling)
- Upload a normal PDF β row goes
scanningβuploadedwithin seconds, then converts normally. - Upload EICAR β row goes
scanningβfailedwithMALWARE_DETECTED; nothing lands in R2. - Check the
malware-scan-findingLambdaβs CloudWatch logs for one[malware-scan]line per object with an outcome.
Rollback
Set UPLOAD_MALWARE_SCAN_ENABLED=false. /upload immediately reverts to
presigning to R2. The staging bucket and plan can stay (they cost nothing when
idle); nothing routes to them while the flag is off.
Notes & limits
- Fail closed.
UNSUPPORTED(e.g. password-protected files, oversized archives),ACCESS_DENIED, andFAILEDscan results all fail the upload β an unscanned file is never admitted to R2. - Scan timeout. If no finding arrives within 5 minutes,
GET /api/files/:id/statusfails the file (SCAN_TIMEOUT). Orphaned staging objects are reaped by the bucketβs 1-day lifecycle rule. - Coverage. GuardDuty is signature-based; it reliably catches known malware but will not catch a novel zero-day exploit crafted against the PDF pipeline. This is a meaningful control, not a guarantee.
- Cost. ~$0.28/GB scanned; an average sub-500KB upload is β $0.00014.
- CF Worker path. The direct
upload-datafallback only runs on the Cloudflare Worker build (non-S3 storage), which has no AWS SDK and is not the production upload path; scanning applies to the Lambda/Node presign + from-url paths.