close
Direct-to-S3 / Azure tus 1.0 resume IndexedDB Golden Retriever 30 locales Webcam & screen capture

File upload security: a practical threat model

An upload endpoint lets an anonymous stranger write bytes to your infrastructure and, usually, get them served back to someone. That is a genuinely dangerous combination, and most of the damage comes from six or seven well-understood mistakes. This page walks each one, and says plainly which are handled by the component and which remain your job.

The one-sentence version: validate on the server on every request, store outside the web root with a name you generated, and serve back with a content type you chose — everything else on this page is detail.

1. Unrestricted upload of dangerous file types

The classic: a user uploads shell.aspx or shell.php, it lands somewhere the web server will execute, and they have a remote shell. This is OWASP's Unrestricted File Upload, and it remains the highest-severity upload bug because it converts "someone uploaded a file" into "someone runs code on your server".

Three controls, in order of importance:

  • Store uploads outside the web root, or in storage with execution disabled. If the file cannot be executed, a bad extension is a nuisance instead of a breach. CoreUpload stores by GUID in a temp directory you configure, not under wwwroot.
  • Allowlist extensions — never blocklist. A blocklist is a list of the attacks you thought of. Set AllowedExtensions and reject everything else.
  • Never trust the client's file name for the stored name. Generate your own identifier and keep the original only as metadata.
builder.Services.AddCoreUpload(o =>
{
    o.TempDirectory      = "App_Data/uploads";   // outside wwwroot
    o.AllowedExtensions  = new[] { ".jpg", ".png", ".pdf" };
    o.MaxFileSizeBytes   = 25 * 1024 * 1024;
});

2. Client-side validation is not validation

Everything the browser checks — the extension list, the size cap, the image dimensions — exists to give the user a fast, friendly error. None of it is a security control. An attacker does not use your page; they post to your endpoint with curl. Every rule you care about must be re-checked server-side, and the server must be the one that decides.

This has a subtle corollary that has bitten real products, including this one: validation must run on every request that can create a file, not just the first. If a chunked upload only validates its first chunk, a client can simply start at chunk 1 and skip the check entirely. If the completion call carries its own file name, that name must be re-validated too, or an approved upload can be renamed at the last step. Both were fixed in 5.2.3 — see the changelog.

3. Content-type spoofing

The Content-Type header is supplied by the client and means nothing on its own. Neither does the extension. If you need to know what a file actually is, read its first bytes:

Check Strength Defeated by
Extension allowlist Essential, cheap A renamed file — content is unchecked
Content-Type header Advisory only Anyone setting a header
Magic bytes (signature) Strong for format identity Polyglots; valid files with hostile payloads
Re-encode / rasterize Strongest for images Costs CPU; changes the file

CoreUpload's client can check magic bytes before uploading (validateMimeByMagic) to catch honest mistakes early, and the server enforces the extension and size allowlists. For untrusted images destined for other users, re-encoding server-side is the strongest answer.

4. Path traversal through file names and session ids

A file name of ../../web.config writes outside your upload folder if you concatenate it into a path. The same applies to any client-supplied identifier used in a path — chunk session ids are the usual forgotten one, because they feel internal.

The robust pattern is to never let client text reach a path at all: store as {guid}.upload, keep the original name in metadata, and validate session ids against a strict character allowlist at a single choke point. CoreUpload does both; the WebForms handler uses the same UploadSessionId guard.

5. Stored XSS through file names

A file name is attacker-controlled text that you will almost certainly display — in a queue, in a notification, in an admin list, in an email. On Linux and macOS a file name may legitimately contain <, > and quotes, and a hostile page can hand a user a file with any name at all via drag-and-drop.

So file names must be escaped for the exact context they land in. Escaping only &, < and > is not enough when the value goes into an attribute: title="…" can be broken out of with a bare quote. Set values with textContent where you can, and use an escaper that handles quotes where you cannot. (5.2.3 fixed exactly this class of bug in the upload queue and toasts.)

6. Resource exhaustion: the quiet one

Denial of service through uploads rarely looks like an attack. It looks like disk usage climbing.

  • Unbounded writes. Any endpoint that streams a request body to disk needs a byte cap enforced during the copy, not just a declared size checked up front — the declared size is a client claim.
  • Orphaned staging data. Chunk and resumable sessions that are started and abandoned must be swept on a timer, or they accumulate forever.
  • Decompression bombs. A 1 MB zip can expand to many gigabytes. If you extract archives, cap the expanded size and the entry count, and reject absolute or .. entry paths (zip-slip).
  • Pixel bombs. A small, valid image can declare enormous dimensions; decoding allocates width × height × bytes-per-pixel. Clamp dimensions before decoding or resizing. The same applies to any URL-driven transform: ?w=25000&h=25000 is a request to allocate gigabytes.
  • Slow clients. Streaming a large file to a slow reader without honoring backpressure buffers it in memory.

7. Who is allowed to read the file back?

This is the question most upload integrations never explicitly answer. By default CoreUpload's download, info and delete endpoints are capability-based: possession of the file's GUID is the credential. The GUIDs are unguessable v4 values, so this behaves like an unlisted share link — fine for many applications, and it works with zero configuration.

It is not the same as authorization, though. A GUID can leak through a referrer header, a log file, a support screenshot or a shared URL, and anyone holding it has access. If files belong to users or tenants, register an authorization handler and make the ownership check explicit:

builder.Services.AddSingleton<IUploadAuthorizationHandler, MyUploadAuthorization>();

public sealed class MyUploadAuthorization : IUploadAuthorizationHandler
{
    public ValueTask<bool> AuthorizeAsync(HttpContext http, Guid fileGuid,
                                          UploadFileAction action, CancellationToken ct = default)
        => ValueTask.FromResult(MyDb.OwnerOf(fileGuid) == http.User.Identity?.Name);
}

Denials return 404, not 403, so a caller cannot use the response to discover which GUIDs exist.

8. Serving files back safely

  • Send X-Content-Type-Options: nosniff so the browser does not second-guess your content type.
  • Prefer Content-Disposition: attachment for anything you did not generate yourself.
  • Serve user content from a separate origin where practical — then even an HTML file that slips through cannot touch your app's cookies or DOM.
  • Never reflect the client's Content-Type back verbatim for a file you have not identified.

9. Direct-to-cloud has its own rules

When the browser uploads straight to S3, Azure Blob or GCS, your server never sees the bytes — it only signs. That moves the security boundary to the signing endpoint:

  • The signing endpoint is a privileged operation. Anyone who can call it can write into your bucket. Authenticate it — the mapping helper returns the route group, so standard authorization applies to every upload and presign endpoint at once.
  • Derive the object key server-side. If the client supplies the key, the client chooses where in your bucket the object lands.
  • Bind constraints into the signature — a content-length range, an expiry short enough to matter, and the content type — because validating a client's claims before signing does not constrain what is actually uploaded afterwards.
  • Validate any URI you accept back. A resumable-session URI that your server then issues authenticated requests against is an SSRF vector unless you check its host.
// Gate every upload and presign endpoint with your normal auth
app.MapCoreUploadEndpoints().RequireAuthorization();

// Or decide per request - useful for tenant quotas or key-prefix rules
public sealed class MyUploadAuthorization : IUploadAuthorizationHandler
{
    public ValueTask<bool> AuthorizeAsync(HttpContext http, Guid fileGuid,
                                          UploadFileAction action, CancellationToken ct = default)
        => ValueTask.FromResult(MyDb.OwnerOf(fileGuid) == http.User.Identity?.Name);

    // Signing has no file GUID yet, so it gets its own hook.
    public ValueTask<bool> AuthorizeSigningAsync(HttpContext http, string operation,
                                                 CancellationToken ct = default)
        => ValueTask.FromResult(http.User.IsInRole("Uploaders"));
}

A denied signing request answers 403, and the check runs before the “signer not configured” branch — so an unauthorized caller cannot learn which cloud providers a deployment has wired up.

Authorizing the endpoint is only half of it. The presign flow spans several requests — create, sign parts, complete, abort — and only create chooses the object key. Every later call re-sends the key and upload id from the client, so unless they are bound to the session the server opened, an authorized caller can still name a different key: choosing where in your bucket the object lands, and completing or aborting uploads it never started.

builder.Services.AddCoreUpload(options =>
{
    // /s3/create returns a signed sessionToken; later calls must present it,
    // and the key + uploadId inside it override whatever the client sends.
    options.RequireSignedCloudSessions = true;
});

The token is protected with ASP.NET Core Data Protection, so key management and rotation come from the framework. Two consequences worth planning for: across multiple servers the data-protection keyring must be shared, exactly as it must be for antiforgery tokens; and isolation between applications depends on the application name being set, which matters on shared hosting. The built-in client captures the token and resends it automatically, including after a page reload.

A short checklist

  • Uploads stored outside the web root, named by a server-generated id
  • Extension allowlist enforced server-side, on every request that can create a file
  • Size cap enforced while writing, not just from the declared size
  • File names escaped everywhere they are displayed, including attributes
  • Abandoned chunk/resumable sessions swept on a timer
  • Image dimensions and archive expansion clamped before decoding/extracting
  • An explicit answer to "who may download this file?"
  • nosniff, sensible Content-Disposition, ideally a separate origin
  • Signing endpoints authenticated; object keys derived server-side
  • Antivirus scanning for anything that will be redistributed to other users

Security is a moving target and this page is a starting point, not a certification. If you are handling regulated data, have the integration reviewed by someone whose job that is.