File Security — Threat Model & Mitigations (Phase 9)
Scope: the Phase-9 Files & Uploads surface (TASK-0101 storage, TASK-0102 pipeline/scan, TASK-0103 UI, TASK-0104 management, TASK-0105 this suite). Companion docs: storage.md (object layout, presigned URLs), files.md (API) (§8/§9 implemented contract), security-architecture.md (platform rules, AGENTS.md rule 19 — never trust frontend authorization).
Every control below is asserted by the security suite (
tests/integration/files-security.integration.test.ts+tests/e2e/specs/files.spec.ts), not just described. The suite writes a machine-readable summary totests/e2e/reports/files-security.jsonfor CI artifacts (TASK-0105 §7).
Prime directive
Uploaded file bytes, names, declared MIME types, and any signed URL are untrusted input (AGENTS.md rule 20). The client is never trusted for authorization (rule 19): the UI renders what it can, but the enforceable facts — size limits, MIME, path safety, access, quarantine — are all decided server-side or by object storage, and every security-sensitive operation writes an audit row (rule 27) with the requesting actor + IP.
1. Threat model
| # | Threat | Attacker goal | Primary control | Verified by |
|---|---|---|---|---|
| T1 | MIME / content spoofing | Upload a web shell / phishing binary disguised as an image | Magic-byte sniff at complete (8 KiB header) — closed allowed-MIME set |
S2 (integration) |
| T2 | Path traversal / key escape | Write outside the workspace prefix, overwrite other objects, escape the bucket | sanitizeFilename + buildFileKey — key is always files/{ws}/{fileId}/{v}/<safe> |
S3 (integration, 200 names) |
| T3 | Malware | Host a virus that spreads on download | ClamAV zCONTSCAN (unix socket) → VIRUS_DETECTED quarantine; no thumbs, no index, no signed URL |
S5 (integration) + E2E blocked card |
| T4 | Plan-limit abuse | Exceed per-file / storage quota (500 MB / 50 GB paid, 50 MB / 1 GB free) | Quota service at session-create (FILE_TOO_LARGE 413, QUOTA_EXCEEDED) |
S1 (500 MB paid) + E2E 60 MB free toast |
| T5 | Unauthorized access | Read / download a file you cannot see | Server-side authz engine (assertCan full file→record→page→database→teamspace chain); explicit NONE hard-deny; 403 + audit |
S4 (integration, 6 cases) |
| T6 | Signed-URL leakage / reuse | Reuse a leaked download URL, or swap it to another file's key | SigV4 presigned URLs: short TTL (900 s), bound to the exact key + method + headers | S4-f (integration) |
| T7 | Quarantine bypass | Download a virus-flagged file via a stale signed URL / the detail endpoint | VIRUS_DETECTED files are never signed; download/preview 403 FILE_QUARANTINED |
S5 (integration) + E2E |
| T8 | Raw key / bucket leak over the wire | Learn object keys or the bucket layout from an API response | The file object over the wire carries no key (only signed URLs + metadata) |
S2 (integration) |
2. Controls & how they map to the code
2.1 MIME / content spoofing (T1)
The upload contract is two-phase: open a session (server presigns a
content-type-bound PUT), then PUT the bytes to storage, then complete.
At complete, the API re-reads the first 8 KiB from storage and runs the
magic-byte sniffer (packages/storage/src/mime.ts):
- The declared
mimeTypemust be in the closed allowed set (UPLOAD_ALLOWED_MIMES), else 400mime_not_allowed. - The sniffed bytes must corroborate the declared type. A
no-signalresult (no magic bytes — e.g. plain text) is tolerated for genuinely extension-less types; a mismatch (png header declared, ascii text found) is fatal:- the row is set to
FAILEDwithmetadata.pipeline.error = "mime_mismatch"(the pipeline never runs on spoofed bytes — no scan, no thumbs, no index); - the object is deleted from the bucket (the bytes never become downloadable);
- audit
file.mime_mismatch(actor = uploader, IP present); - the client gets 415
MIME_MISMATCH.
- the row is set to
Conflict recorded (rule 29): TASK-0105 Requirement 4 says 422
MIME_MISMATCH; the implemented, architecture-consistent contract (and the
TASK-0103/0104 suites) is 415. Recorded in docs/tasks/phase-09-files.md.
2.2 Path traversal / key escape (T2)
Object keys are derived, never user-supplied. buildFileKey stores
files/{workspaceId}/{fileId}/{version}/{safeName} where safeName = sanitizeFilename(name):
- strips all path components across both
/and\separators (and Windows drive prefixes) →../../../etc/cron.d/x⇒x; - replaces every char in
[\x00-\x1f\x7f<>:"\\/*?|]with_(control chars, DEL, and the filesystem-forbidden set) →bad\x00name.png⇒bad_name.png; - caps the segment at 180 chars preserving the extension.
The display name is the original (recognizable) name; only the key is
sanitized. Because the key is a pure function of (workspaceId, fileId, version, name) and every path separator is removed, a key can never contain
/, \, NUL, or a traversal segment — it cannot escape the
files/{workspaceId}/ prefix. The S3 client is also the oracle: the
integration test asserts the object physically lands at exactly the
sanitized key, and that parseFileKey round-trips it.
Traversal is a segment-level invariant. A name that merely contains the dot-dot substring (
a..b.png) is a single harmless object name in a flat S3 namespace — the dangerous case is..as a path component, whichsanitizeFilenameremoves. The S3 test asserts no.././NUL/control segment and the workspace prefix on all 200 generated hostile names.
2.3 Malware (T3)
The pipeline's scan step runs ClamAV over the local download:
CLAMD_SOCKETset →zCONTSCANover the unix socket (whatclamdscanuses; the reliable path — the TCPzINSTREAMis broken in the pinned ClamAV image, recorded inclamav.ts);- socket + host empty → dev-skip,
scanReport { engine:"none", skipped:true }; - production with neither → fail closed (boot/scan fails — never a silent pass).
On infected: the row is set to VIRUS_DETECTED (a terminal state —
re-scans are no-ops), audit file.virus_detected fires, and the finalize step
records scanReport { engine:"clamav", infected:true, signature }. Critically,
the virus path never reaches the metadata/thumbnail/index steps — so
no thumbnails are generated, no search doc is published (the search
processor only indexes on file.uploaded/file.finalized, which a virus file
never emits), and the object is retained for forensics. The EICAR standard
test signature is the fixture — it is a known, non-malicious marker string
ClamAV is shipped to detect, so the suite can prove the whole chain safely.
2.4 Plan limits (T4)
apps/api/src/modules/files/quota.ts enforces, at session create (before
any bytes move):
| Plan | max file | storage quota |
|---|---|---|
| free | 50 MB (52,428,800 B) | 1 GB |
| paid | 500 MB (524,288,000 B) | 10 GB |
effectiveMaxFileSize = min(planLimit, usageLimits.maxFileSize if > 0, LIMITS.MAX_FILE_SIZE). Over-limit → 413 FILE_TOO_LARGE with a message
naming the limit; quota-exceeded → QUOTA_EXCEEDED. The web client pre-checks
file.size > maxFileSize and toasts before opening a session (no request
sent) — a UX fast-path, not the authority (the API re-checks; the 413 is
the source of truth).
Decimal-vs-binary note (rule 29): the ROADMAP/suites speak of a "500 MB" boundary. The plan limit is 500 MiB (524,288,000 B). The sparse fixture is 500,000,000 decimal bytes — below the paid limit, so it passes on paid and would 413 on free. Recorded in the task file.
2.5 Unauthorized access (T5)
requireReadable (files.service) resolves the full named chain
(file → record → page → database → teamspace → workspace) via assertCan:
- role floor: OWNER/ADMIN = FULL_ACCESS, MEMBER = EDIT, GUEST = VIEW, non-member = NONE;
- grants are maxed by specificity (workspace < teamspace < database < page < record < file) — a file grant has the highest specificity;
- an explicit
NONEgrant is a hard deny at any level in the chain (a page-levelNONEbeats an inherited workspaceEDIT); - a read that fails the floor returns 403 and audits
file.download_denied(or metadata denial) withreason, the requesting actor (neversystem), and the IP; a successful read auditsfile.downloaded.
Search visibility is workspace/page-scoped by design — file-level grants are enforced at read time, so revoking a file grant does not re-index the search doc (recorded rule-29 note, carried from TASK-0104).
2.6 Signed-URL lifecycle (T6) & 2.7 quarantine (T7)
Downloads/previews are 302 redirects to a SigV4 presigned GET minted
by the API with a short TTL (S3_SIGN_EXPIRY_SECONDS, default 900 s), bound to
the exact key, method, and (for uploads) content-type. Consequences the
suite asserts:
- a URL minted in the past (TTL expired) → 403 from storage;
- a URL for file A whose path is swapped to file B's key → 403
SignatureDoesNotMatch(the signature covers the key — a leaked URL is useless on any other file); - a
VIRUS_DETECTEDfile is never signed —download/preview→ 403FILE_QUARANTINED, while the detail endpoint (allowQuarantined) returns 200 without signed URLs (so a stale preview URL can't resurrect a quarantined object).
2.8 No raw key over the wire (T8)
The file object returned by every endpoint omits key (and the bucket
name). The client only ever receives signed URLs + display metadata. The S3
test asserts GET /files/:id has no key property.
3. Re-running the suite
# Prereq: the host stack is up (Postgres/Redis/MinIO/OpenSearch/ClamAV).
# The suite is gated on MinIO; the OpenSearch + ClamAV assertions are gated on
# those hosts being reachable (they skip, not fail, when absent).
# Integration (the security matrix — 500 MB, MIME, traversal, authz, EICAR,
# signed URLs + the report artifact). ~30 s on a warm host.
pnpm --dir tests/integration exec vitest run files-security
# E2E happy paths + the 60 MB free-plan toast + picker thumbnail +
# unauthorized block state (Playwright). Requires the full web+api+worker
# stack (pnpm docker:up && pnpm db:deploy && pnpm dev) + CI-rate-limit env:
# RATE_LIMIT_AUTH=100000 RATE_LIMIT_API=100000
pnpm --dir tests/e2e exec playwright test specs/files.spec.ts
The integration suite writes
tests/e2e/reports/files-security.json (scenario → pass/fail + timing +
total, against the 15-minute CI budget). A performance guard test fails
the run if the total exceeds 15 minutes (the 500 MB upload is included in
the budget — it is a CI-regression canary). CI uploads
tests/integration/*.report.* / the e2e reports as artifacts; any failure
blocks the phase (TASK-0105 exit criterion).
EICAR, explained
EICAR (X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*,
69 bytes) is the standard anti-virus test signature. It is not
malware — it is a known marker that every major scanner (ClamAV, and others)
is required to flag, so vendors can verify a scanner is active without
shipping a real virus. Uploading it exercises the full detect→quarantine→
deny chain deterministically. (Never put a real malicious sample in a repo
or a test fixture.)
4. Residual risk & out-of-scope
- Content beyond the 8 KiB header: the
complete-time sniff reads the first 8 KiB (magic bytes live at offset 0). A file with a valid header and a malicious tail (a polyglot) passes the sniff; the ClamAV scan is the control for actual malicious content, and it scans the whole object. - Zip-bomb / resource DoS via a large-but-allowed file: bounded by the
per-file plan limit + the storage quota + the worker's temp-dir disk guard
(
FILES_TEMP_FREE_MIN_BYTES). - Search does not re-index on file-grant revoke (read-time model, §2.5) — a member of the workspace can still find a file in search and then get 403 on open; the doc is scoped by the workspace floor by design.
- Frontend is never the authority for any of the above (rule 19); this document describes the server/storage controls the client depends on.