# Storage AV Scanning Defense-in-depth model for user-uploaded files. Two layers: - **Layer 1 — Magic-Bytes Pre-Upload Scan**. Synchronous, runs in the upload request handler **before** the bytes are committed to the storage backend (S3 / local). Validates the actual content type via signature detection and rejects mismatches or disallowed types. - **Layer 2 — ClamAV Post-Upload Scan**. Asynchronous BullMQ job that runs after the object is in the bucket. Hits trigger server-side quarantine (CopyObject + DeleteObject into a `quarantine/` prefix). ## How it works The `MagicBytesScanAdapter` in `@toolctrl/storage` reads the first ~4100 bytes of an upload buffer and resolves its MIME type via the `file-type` library's signature database. It then validates the detected type against: 1. The client-declared `Content-Type` (rejects mismatches — common attack vector: an `.exe` smuggled as `image/png`). 2. A per-context allow-list (rejects e.g. a PDF uploaded into the avatar context — see allow-list table below). 3. The filename — control characters (`\x00-\x1f`, `\x7f`) and path traversal sequences (`../`, `..\\`) are rejected; filenames over 255 chars are rejected. ## Allow-list defaults See `packages/storage/src/scan/allow-list.ts` for the canonical list. | Context | Allowed MIME types | |---|---| | `avatar` | image/png, image/jpeg, image/webp | | `mediaLibrary` | + image/gif, image/svg+xml, image/x-icon, image/vnd.microsoft.icon | | `emailAttachment` | image/{png,jpeg,webp,gif} + application/pdf, text/plain, text/csv, application/zip, Office .docx/.xlsx/.pptx | | `formUpload` | image/{png,jpeg,webp} + application/pdf | | `kbAsset` | image/{png,jpeg,webp} + application/pdf | Per-context override via env: `STORAGE_SCAN_ALLOWLIST_` accepts a comma-separated list of MIME types and **replaces** the default for that context (not merged). Contexts in env-var form: `AVATAR`, `MEDIA_LIBRARY`, `EMAIL_ATTACHMENT`, `FORM_UPLOAD`, `KB_ASSET`. Example: allow only PNG and JPEG for tenant avatars in production: ``` STORAGE_SCAN_ALLOWLIST_AVATAR=image/png,image/jpeg ``` ## Notfall-Bypass Set `STORAGE_SCAN_LAYER1_ENABLED=false` to disable scanning entirely (the adapter returns `status: "skipped"` for every call). Intended for emergency incident response only — do not run with scanning disabled in production for any extended period. ## How callers wire it in `storeFile` from `apps/web/app/utils/integrations/storageService.ts` accepts an optional `scan` parameter: ```ts import { storeFile } from "~/utils/integrations/storageService"; import { auditRejectedUpload } from "~/utils/integrations/storageAudit.server"; await storeFile({ bucket: "users-icons", content: avatarDataUrl, id: userId, scan: { context: "avatar", filename: `${userId}.avatar`, onReject: auditRejectedUpload(request, "tenant.profile.avatar"), }, }); ``` A rejected scan throws `StorageScanRejectedError` from `storageService.ts`. The error carries the bucket, id, filename and full `PreUploadScanResult` (status, reason, detail, detectedMimeType). Callers can catch it and map to a 415 response with a user-friendly message. The `onReject` callback fires before the error throws and is awaited, so the audit record is persisted even when the route swallows the error. ## Audit events `auditRejectedUpload(request, context)` writes a `createAdminLog`-style row with action `upload.rejected.scan`. The serialised detail field contains: ```json { "context": "tenant.profile.avatar", "reason": "mime_mismatch", "detail": "declared=\"image/png\", detected=\"application/x-msdownload\"", "detectedMimeType": "application/x-msdownload" } ``` The buffer content is never written to the audit log. Only metadata (bounded reason enum + adapter-level diagnostic string + detected MIME). ## Currently-scanned upload paths | Route | Context | Buckets | |---|---|---| | `apps/web/app/routes/admin/settings/general.tsx` | `mediaLibrary` | branding (logo, dark logo, icon, dark icon, favicon) | | `apps/web/app/routes/admin/settings/profile.tsx` | `avatar` | users-icons | | `apps/web/app/routes/app.$tenant/settings/profile.tsx` | `avatar` | users-icons | | `apps/web/app/routes/app.$tenant/settings/account.tsx` | `avatar` | accounts-icons | | `apps/web/app/routes/app.$tenant/__portals/portals.$portal/settings.tsx` | `mediaLibrary` | branding (5), seo (2) | | `apps/web/app/routes/app.$tenant/__widgets/widgets/$id.tsx` | `mediaLibrary` | widgets | Any new upload route that takes user-provided file bytes **must** opt into the scan by passing a `scan` parameter. Server-internal uploads (e.g. seed data, automated reports) may skip it because the bytes never came from a user — but this is the only legitimate reason to omit `scan`. ## Limits & known gaps - **Polyglot files** (a valid JPEG with an embedded JS payload exploiting an ImageMagick CVE) pass Layer 1 because the magic-bytes signature is still a valid JPEG. Layer 2 (`#17`) closes this with content scanning. - **SVG XSS**: SVG is permitted in the `mediaLibrary` context. Files containing `