Skip to main content
View as Markdown

Upload Security

File upload is the classic soft spot of a data application: it accepts attacker-chosen bytes, an attacker-chosen name, and an attacker-chosen content type, then serves all three back to a browser. Sovrium's answer is that every one of those three is validated server-side, in a fixed order, before anything is written.

Before Anything Is Stored

Validation runs in this sequence. Each step is total — nothing "sanitizes and continues".

Step Check Rejection
1 Filename contains .., /, \ — path traversal. 400
2 Filename contains a null byte. 400
3 Size exceeds the bucket's maxFileSize, or STORAGE_MAX_FILE_SIZE. 413
4 MIME type is not in the bucket's allowedMimeTypes. 400
5 Bucket is private and the request has no session. 401
6 Storing the file would exceed STORAGE_MAX_TOTAL_SIZE. 507

Steps 1 to 4 run before the auth check. That ordering is intentional: an oversized or malformed upload is rejected on its own merits, so an unauthenticated client cannot use the auth boundary to learn whether its payload would otherwise have been accepted.

An explicit path field follows a slightly different rule set — / is allowed, since path prefixes are the whole point, but a leading /, an empty value, .., \ and null bytes are all rejected.

MIME Allow-Lists

allowedMimeTypes is the bucket's statement about what belongs in it. Entries match exactly, or as a type/* prefix wildcard:

buckets:
  - name: avatars
    allowedMimeTypes: [image/*] # image/png ✓  image/svg+xml ✓  application/pdf ✗

  - name: invoices
    allowedMimeTypes: [application/pdf] # exact match only

Omitting the property accepts everything. That is fine for an internal bucket and reckless for one that any authenticated user can write to — an allow-list is the cheapest control on this page, and the only one that expresses intent rather than merely blocking an attack.

What a Served File Carries

Downloads — direct or through a signed URL — are served with three headers whose combined job is to make a stored file inert in a browser:

Header Value Why
X-Content-Type-Options nosniff Stops the browser second-guessing the type and executing the bytes.
Content-Security-Policy default-src 'none' Even if rendered, no script, style or network access is permitted.
Content-Disposition attachment (or inline — see below) Downloads rather than renders, in a context where that matters.

Content-Disposition is the one with nuance. Only raster images — PNG, JPEG, GIF, WebP — are ever served inline, and then only through the signed-download route where inline display is the point. Everything else is forced to attachment, and image/svg+xml is explicitly excluded from the inline set: an SVG is a document that can carry <script>, so serving one inline on your own origin is stored XSS. Non-ASCII filenames are emitted with RFC 5987 filename*=UTF-8''… so a header never breaks on a multi-byte name.

What This Does Not Cover

Two things the platform will not do for you:

  • No virus scanning. Bytes are stored as received. If your threat model includes malware distribution, put a scanner in front of the upload endpoint or behind an automation.
  • No content-based type detection. See the callout above — declared types are trusted for the allow-list decision.

Last updated July 27, 2026

This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta — contributions and corrections are welcome.

Built with Sovrium