Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
# 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_<CONTEXT>` 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 `<script>` tags can execute when rendered in a permissive
context. CSP headers + DOMPurify on the render path are the right
mitigation; sanitisation is out of scope for this task — track separately.
- **Text/plain and CSV** cannot be detected from magic bytes (no signature).
The scanner accepts them when the declared MIME matches `text/plain` or
`text/csv` AND the allow-list permits — this is a known weak point.
- **Rate limiting** on upload endpoints is the responsibility of the route
itself. The scanner does not gate request rates.
---
# Layer 2 — ClamAV Post-Upload Scan
## Components
- **ClamAV container**: `clamav/clamav:stable` in `docker-compose.yml` with a
named volume for the signature DB. First boot pulls ~250 MB of signatures
via freshclam — the health check's `start_period` is set to 1200 s to
cover that. Subsequent boots pass in under 2 s.
- **`createClamAVStorageScanner(...)`** in `@toolctrl/storage` —
StorageScanner implementation. Talks to clamd over TCP via the `clamscan`
npm package. Pings before every scan; throws `ClamAVUnreachableError`
when clamd is unreachable.
- **`createS3StorageQuarantineAdapter(...)`** — StorageQuarantineAdapter.
Server-side `CopyObjectCommand` into `quarantine/<key>` (or a dedicated
`STORAGE_QUARANTINE_BUCKET`), then `DeleteObjectCommand` on the source.
Writes `scan-signature`, `scan-quarantined-at`, `scan-source-bucket` and
`scan-source-key` as S3 metadata so an admin restore action has a full
back-pointer.
- **`createWorkerScanService(env)`** in `apps/worker/src/scanService.ts` —
wires the scanner + quarantine adapter together via
`createStorageScanService` from `@toolctrl/storage`. Returns `undefined`
when STORAGE credentials are missing; the worker handler then short-
circuits the scan-file job rather than running blind.
- **`enqueueFileScan(...)`** in `apps/web/app/utils/integrations/
fileScanQueue.server.ts` — producer-side helper that opens a
`TypedQueue<typeof queueNames.fileScan>`, enqueues a `scan-file` job
built from `buildFileScanJob`, and closes the queue.
## Enabling Layer 2
Layer 2 is **opt-in** via two env knobs:
- `SCAN_QUEUE_ENABLED=true` on both the web and worker processes. On the
web side the producer helper short-circuits without it; on the worker
side `getWorkerQueueNames` appends `toolctrl.file-scan` only when set.
- `CLAMAV_HOST` (default `clamav`), `CLAMAV_PORT` (default `3310`),
`CLAMAV_TIMEOUT_MS` (default `30000`). Defaults match the compose
service. Override for staging/prod when ClamAV lives on a separate host.
- `STORAGE_QUARANTINE_BUCKET` (optional) — dedicated bucket for infected
objects. Defaults to the source bucket with the `quarantine/` prefix.
Existing storage env (`STORAGE_REGION`, `STORAGE_ACCESS_KEY_ID`,
`STORAGE_SECRET_ACCESS_KEY`, etc.) is reused for both the scanner's GET
path and the quarantine adapter.
## How callers wire it in
`storeFile`'s `scan` option already learnt about Layer 1; for Layer 2 set
`enqueuePostScan: true`:
```ts
await storeFile({
bucket: "users-icons",
content: avatarDataUrl,
id: userId,
scan: {
context: "avatar",
filename: `${userId}.avatar`,
onReject: auditRejectedUpload(request, "tenant.profile.avatar"),
enqueuePostScan: true,
tenantId,
},
});
```
After a successful PUT, `storeFile` dynamically imports
`fileScanQueue.server` and enqueues. Failures to enqueue are swallowed (a
queue write hiccup must not block the synchronous user-visible upload);
they should be picked up by Sentry once #29 lands.
## When ClamAV is down
The worker's scan-file handler routes through `createStorageScanService`
which wraps the scanner's `ClamAVUnreachableError` into a `StorageScanResult`
with `verdict: "error"`. The job is retried per BullMQ's default policy; if
the outage persists, the job ends up in the failed-jobs DB row written by
the existing `recordQueueFailedJob` path. The synchronous upload remains
unaffected — fail-open by design.
When clamd is reachable but the signature DB is stale (freshclam hasn't
run for >24 h), the operator should restart the container or trigger
freshclam manually. The next task (`#29` Sentry self-host) gives us
alerting on signature staleness.
## EICAR smoke test
The standard EICAR test file (`X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-...`) is
recognised by every AV engine including ClamAV. Use it for staging
verification:
1. Upload an `eicar.txt` (renamed to `.png` and re-typed to `image/png`
so it passes layer 1) into the media library.
2. Within ~5 s the worker should run the scan-file job, get an "infected"
verdict with signature `Win.Test.EICAR_HDB-1`, and quarantine the
object.
3. Verify: `s3://<source-bucket>/<key>` no longer exists; the corresponding
`quarantine/<key>` does, with the `scan-signature` metadata header set.
## Known limits
- **DB tracking is deferred.** The `media.scanStatus` / `media.scanResult`
columns from the original detail-MD are not added yet. Today the only
durable signal that a file was quarantined is the absence of the
source-key + the presence of the quarantine copy + the S3 metadata. A
follow-up commit will add the columns + the UI badge.
- **User notification on quarantine is deferred.** No email is sent today.
Operator can watch the worker logs / S3 access logs.
- **Concurrency**: BullMQ defaults to a per-worker concurrency of 1.
Bursty uploads will queue. Raise via the standard worker config knobs
once production traffic justifies it.