Receiving a multipart request and writing bytes into object storage completes only the transfer. A client can forge the extension and Content-Type; an image can carry active content or trigger a parser bug; a tiny archive can expand into gigabytes; and the original filename can become a path-traversal primitive. Returning a public URL immediately makes every later check too late.

Secure upload is a stateful content-processing pipeline: bound the cost of admission, keep unknown bytes in quarantine, publish only after verification and transformation, and continue enforcing authorization and response policy at download time.

Separate “received” from “available” with states

RECEIVING -> QUARANTINED -> SCANNING -> READY
                                  \-> REJECTED

The upload API first creates a session with an owner, tenant, intended use, and size ceiling. Completing byte transfer returns 202 Accepted or a QUARANTINED resource, not a business-accessible URL. An idempotent scanning worker advances the object to READY or REJECTED; clients poll status or receive an event.

Use conditional state updates so duplicate finalization, reordered callbacks, or repeated workers cannot publish an object twice. Exactly one transition makes an object READY, and it records the policy version, scanner version, and server-computed content digest that passed.

Bound cost at the outermost layer

Configure consistent request-size, read-time, and concurrency limits at the gateway, application, and storage service. Checking size only after application code has buffered the whole body means hostile traffic already consumed bandwidth, memory, and temporary disk. Multipart or resumable upload also needs limits on part count, aggregate size, session lifetime, and unfinished sessions per principal.

Authenticate and authorize the intended destination before accepting bytes. An avatar must belong to the current user; a contract attachment must target a project the caller may modify. Quotas may need separate measures for compressed bytes, expanded bytes, or transcoding time instead of file count alone.

A client checksum can detect transfer corruption and support idempotency, but it does not establish safety. Compute a digest while streaming on the server, then verify object length, digest, owner, and session state during finalization.

Treat the filename as display metadata

Never use the submitted filename as a filesystem path or object key. Generate a random storage identifier. Normalize the original Unicode, cap its length, reject controls, and keep it only as display metadata. Remove client path components and reject empty names, reserved names, trailing dots, slashes, backslashes, and NUL.

Use an allowlist of extensions required by the product, evaluated after full protocol decoding and normalization. This prevents trivial bypasses such as invoice.pdf.exe, casing variants, and double encoding. Renaming isolates path and overwrite risks; it does not change the content’s type.

Type decisions need multiple signals

The request Content-Type is an untrusted claim. Extension, declared media type, signature bytes, and successful strict parsing should agree. Reject a mismatch or route it to explicit review. Magic bytes alone are not proof: a polyglot can satisfy multiple parsers, and harmful content can still have a valid header.

Allow only formats the business actually uses and parse them with maintained, format-specific libraries. Decode and re-encode images to a fresh object and remove unnecessary metadata. Apply content disarm and reconstruction to supported documents where appropriate. Publish the derived object; retain the original under stricter quarantine permissions or delete it according to policy.

Give archives their own resource budget

Do not unpack an archive unless the product requires it. When extraction is necessary, limit entry count, recursion depth, per-entry and total expanded bytes, compression ratio, filename length, and processing time. Validate every extracted entry again.

Normalize each target path and prove it remains under the temporary root. Reject absolute paths, .., device files, hard links, and symbolic links escaping the root. Inspect the manifest first and stream extraction, aborting and cleaning up on any budget violation. Never assume declared sizes equal actual output.

Quarantine must not be a download location

Store quarantined objects in a private bucket, separate account, or at least a separate prefix. The application host must not execute them or expose them through a static web root. For direct-to-object-storage uploads, scope a short-lived presigned capability to one principal, random key, and limited operation. Finalization still rechecks the object server-side; a successful upload request is not proof of a valid file.

Run malware scanners, parsers, and transcoders with low privilege and bounded CPU, memory, disk, and network. If scanning is unavailable, remain in SCANNING or fail closed—never publish automatically for availability. When the scanner or policy changes, retain enough metadata to locate READY objects passed by old versions and rescan according to risk.

Download is a second security boundary

Authorize the file record on every download; an unguessable URL is not permission. By default, respond from a download origin separate from the application with a known Content-Type, X-Content-Type-Options: nosniff, and RFC 6266 Content-Disposition: attachment. Generate safe filename and filename* values server-side instead of copying the submitted name into a header.

Use inline only for explicitly supported safe derivatives, with a restrictive CSP. Active uploads such as HTML or SVG should not share the application’s origin, where they could read same-origin data or make authenticated requests.

Lifecycle is part of the security control

Expire unfinished sessions, failed parts, rejected originals, and unreferenced quarantine objects. Retention policy must distinguish forensics, privacy deletion, and recovery. Cleanup must verify database ownership before deleting storage objects so it cannot remove a live reference. Audit who uploaded and downloaded, policy outcomes, and object versions without recording sensitive content.

Tests should include forged MIME, double extensions, path traversal, Unicode and controls, empty and oversized streams, polyglots, malformed parsers, archive bombs, recursive archives, scanner timeout, duplicate finalization, reordered callbacks, unauthorized quarantine download, and response-header injection. Test scanners with their harmless dedicated marker, not real malware in an ordinary test environment.

Monitor time in every state, rejection reason, expansion ratio, scan queue depth, engine errors, orphan objects, denied downloads, and policy-version distribution. Alerts should distinguish an increase in malicious submissions from scanner infrastructure failure.

An upload is not complete when bytes reach storage. It is complete when cost is bounded, type is verified, processing is isolated, publication is traceable, and delivery still obeys authorization and browser security boundaries. Modeling those steps as a state machine turns file upload from an arbitrary-content entrance into a controlled product capability.