Skip to content

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 closed

Components

PieceWhere
scanning file status + staging_key / scan_started_at columnsmigration 302_file_scanning_status.sql
Presign-to-staging + from-url staging + 5-min scan-timeout sweepworkers/api/src/routes/files.ts (gated by isMalwareScanEnabled())
Scan decision logicworkers/api/src/services/malware-scan.ts
Finding-handler Lambdaworkers/api/src/lambda-scan-finding.ts
Staging bucket, GuardDuty plan, IAM role, EventBridge rule, finding Lambdainfra/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)

  1. 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; or aws guardduty create-detector --enable.)
  2. Deploy the stack. cdk deploy creates the staging bucket, the CfnMalwareProtectionPlan, the IAM role GuardDuty assumes, the finding Lambda, and the EventBridge rule β€” all while the flag is still off.
  3. 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/Error EventBridge events if it does not β€” usually an IAM role permission gap).
  4. 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 / blocked respectively.
  5. Flip the flag. Set UPLOAD_MALWARE_SCAN_ENABLED=true on the …-api Lambda (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 β†’ uploaded within seconds, then converts normally.
  • Upload EICAR β†’ row goes scanning β†’ failed with MALWARE_DETECTED; nothing lands in R2.
  • Check the malware-scan-finding Lambda’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, and FAILED scan 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/status fails 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-data fallback 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.