File uploads remain one of the fastest ways a production ASP.NET site on IIS gets burned. A single unrestricted endpoint can fill the disk, overwrite content, or plant executable content under the site root. Shared and VPS Windows hosts amplify the blast radius because app-pool identities, temp folders, and publish layouts are predictable.

Treat every upload as hostile input. Validate in the app, enforce hard limits in IIS, store bytes outside wwwroot, and serve downloads through a controlled handler—not as static files the client can guess.

#What goes wrong on IIS-hosted apps

Classic failure modes show up repeatedly on Windows hosts: no max request body, trusting the client Content-Type or file extension, saving under wwwroot/uploads with execute permissions, and using the original filename as the on-disk name. ASP.NET Core model binding will happily accept IFormFile; IIS will accept the body unless requestFiltering and the ASP.NET Core Module limits say otherwise.

  • Disk exhaustion from large or repeated multipart posts
  • Path traversal via filenames like ..\web.config or encoded separators
  • Stored malware or HTML that later executes or phishes when served statically
  • Accidental exposure of web.config, appsettings, or Data Protection keys if uploads land in the site tree

#Cap size early: Kestrel, IIS, and forms

Defense starts before your controller runs. Set matching ceilings so a huge body dies at the edge instead of buffering into the app-pool temp directory. For .NET 10 apps, configure form options in code and mirror the limit in web.config for IIS.

csharp
builder.Services.Configure<FormOptions>(o =>
{
    o.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5 MB
    o.ValueLengthLimit = 1024 * 1024;
});

builder.WebHost.ConfigureKestrel(o =>
{
    o.Limits.MaxRequestBodySize = 5 * 1024 * 1024;
});
xml
<system.webServer>
  <security>
    <requestFiltering>
      <requestLimits maxAllowedContentLength="5242880" />
    </requestFiltering>
  </security>
  <aspNetCore maxRequestBodySize="5242880" />
</system.webServer>

maxAllowedContentLength is bytes and applies to the IIS request pipeline. Keep it equal to (or slightly above) the ASP.NET Core limit so clients get a consistent failure instead of a hung upload. Restart the app pool after web.config changes so the ASP.NET Core Module reloads.

#Validate content, not just the extension

Allow-list extensions and MIME types, then verify the file header (magic bytes). Never trust FileName or ContentType alone. Generate your own storage name (GUID + safe extension), reject path characters, and cap dimensions or page counts for images and PDFs when the feature allows it.

csharp
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
    { ".jpg", ".jpeg", ".png", ".pdf" };

var ext = Path.GetExtension(file.FileName);
if (string.IsNullOrEmpty(ext) || !allowed.Contains(ext))
    return Results.BadRequest("File type not allowed.");

if (file.Length <= 0 || file.Length > 5 * 1024 * 1024)
    return Results.BadRequest("File size out of range.");

// Own the name; never use the client path
var safeName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}";
var root = Path.Combine(env.ContentRootPath, "App_Data", "uploads");
Directory.CreateDirectory(root);
var fullPath = Path.GetFullPath(Path.Combine(root, safeName));
if (!fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase))
    return Results.BadRequest("Invalid path.");

await using var stream = File.Create(fullPath);
await file.CopyToAsync(stream);

App_Data under the site is a common Windows layout, but only if the directory is not browsable and not executable. Prefer a folder outside the public site root when your host layout allows it (for example a sibling directory the app-pool identity can write). Store only the generated name in SQL Server; never concatenate user text into paths.

#IIS: no execute, no directory browse, tight auth

If uploads must live under the site, lock the folder down in IIS Manager or with a location-specific web.config: remove Script and Execute permissions, disable directory browsing, and deny anonymous listing. Serve files through an authenticated endpoint that checks ownership in SQL Server and returns FileStreamResult with a fixed Content-Type—not through static file middleware pointed at the upload tree.

  • Strip execute permission on the upload directory; static read-only via your handler only
  • Block dangerous extensions at IIS requestFiltering even if the app allow-lists types
  • Run the app pool as a least-privilege identity with write only to the upload path
  • Scan or quarantine asynchronously if your threat model includes malware droppers
xml
<location path="uploads">
  <system.webServer>
    <directoryBrowse enabled="false" />
    <handlers accessPolicy="Read" />
    <security>
      <requestFiltering>
        <fileExtensions>
          <add fileExtension=".exe" allowed="false" />
          <add fileExtension=".dll" allowed="false" />
          <add fileExtension=".config" allowed="false" />
        </fileExtensions>
      </requestFiltering>
    </security>
  </system.webServer>
</location>

#SQL Server metadata, not blobs by default

Persist filename, content type, byte length, SHA-256, uploader user id, and created UTC in SQL Server (2022 on many Windows shared plans; 2025 in newer environments). Keep the binary on disk or object storage unless you have a deliberate reason for varbinary. That split lets you enforce per-user quotas with a simple aggregate query and delete orphaned rows when cleanup jobs remove files.

When serving, re-check the row against the current principal. Set Content-Disposition to attachment for non-image types so browsers download instead of render. Prefer HTTPS-only cookies and auth on the download route so hotlinking anonymous upload URLs does not bypass your checks.

#Practical takeaway

Ship three layers together: identical body size limits in ASP.NET Core and IIS, allow-listed types with server-generated names under a non-executable folder, and metadata-backed download handlers. On the next deploy, verify maxAllowedContentLength, confirm the upload path is not under a static-file root, and attempt a rejected extension and an oversized POST so you see the failure mode before attackers do. That checklist is enough to close most upload incidents on Windows Server and IIS without waiting for a broader rewrite.