Decide the type from the bytes, and never let the client pick the name. Limit the endpoint with WithFormOptions(multipartBodyLengthLimit), require the extension, IFormFile.ContentType and the PNG or JPEG signature to agree, save under RandomNumberGenerator.GetHexString() outside the web root, and serve with Results.File(..., fileDownloadName) plus X-Content-Type-Options: nosniff.
- Find every endpoint that reads IFormFile or IFormFileCollection and where the file goes: Path.Combine() with file.FileName, a folder under the web root that UseStaticFiles serves, or a check that only looks at file.ContentType.
- Put the feature's limit on the endpoint with .WithFormOptions(multipartBodyLengthLimit: limit), so ReadFormAsync throws InvalidDataException once a part is larger, and answer that with 413.
- Accept the file only if Path.GetExtension(file.FileName) is on your allowlist (compare case-insensitively), file.ContentType is the type that extension names, and the content starts with that type's signature (89 50 4E 47 0D 0A 1A 0A for PNG, FF D8 FF for JPEG). Answer 415 otherwise.
- Write it under RandomNumberGenerator.GetHexString(32, lowercase: true) plus the extension of the verified type, in a folder outside the web root, with FileMode.CreateNew so an existing file is never replaced.
- Serve it from an endpoint that accepts only names your code generates, sets response.Headers.XContentTypeOptions = "nosniff" and returns Results.File(bytes, contentType, fileDownloadName: name), which sends Content-Disposition: attachment. Or serve uploads from a separate origin.
- Add regression tests with this card's payloads: an HTML page and an SVG with script, HTML named avatar.png, avatar.png.html, a PNG that is also a page, ../ and absolute file names, a file one byte over the limit, and two users uploading the same name.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
app.MapPost("/avatars", async (HttpRequest request, AvatarFolders folders) =>
{
var file = (await request.ReadFormAsync()).Files.GetFile("avatar");
if (file is null || file.FileName.Length == 0)
{
return Results.BadRequest(new { error = "attach the image in the \"avatar\" field" });
}
await using (var stored = File.Create(Path.Combine(folders.WebRoot, "uploads", file.FileName)))
{
await file.CopyToAsync(stored);
}
// UseStaticFiles serves the web root, so the file is public as soon as it is written.
var url = "/uploads/" + Uri.EscapeDataString(file.FileName);
return Results.Created(url, new { url });
});With IMPL=vulnerable, 9 of 13 tests fail (9 exploit tests).
The fix: check and store the upload
private const int MaxBytes = 512 * 1024;
private sealed record ImageType(string MediaType, string Extension, byte[] Signature);
private static readonly ImageType Png = new("image/png", ".png", Convert.FromHexString("89504E470D0A1A0A"));
private static readonly ImageType Jpeg = new("image/jpeg", ".jpg", Convert.FromHexString("FFD8FF"));
// The only types this feature needs, keyed by the extensions that may name them.
private static readonly Dictionary<string, ImageType> Types = new(StringComparer.OrdinalIgnoreCase)
{
[".png"] = Png,
[".jpg"] = Jpeg,
[".jpeg"] = Jpeg,
};
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/avatars", async (HttpRequest request, AvatarFolders folders) =>
{
IFormFile? file;
try
{
file = (await request.ReadFormAsync()).Files.GetFile("avatar");
}
catch (InvalidDataException)
{
// The form reader throws once a part passes WithFormOptions' limit (below), before
// reading the rest of the body.
return Results.Json(new { error = "avatars must be at most 512 KiB" }, statusCode: StatusCodes.Status413PayloadTooLarge);
}
if (file is null || file.FileName.Length == 0)
{
return Results.BadRequest(new { error = "attach the image in the \"avatar\" field" });
}
using var buffer = new MemoryStream();
await file.CopyToAsync(buffer);
var content = buffer.ToArray();
// GetExtension() returns the last extension only: ".html" for avatar.png.html.
if (!Types.TryGetValue(Path.GetExtension(file.FileName), out var type)
|| !string.Equals(file.ContentType, type.MediaType, StringComparison.OrdinalIgnoreCase)
|| !content.AsSpan().StartsWith(type.Signature))
{
return Results.Json(new { error = "avatars must be PNG or JPEG images" }, statusCode: StatusCodes.Status415UnsupportedMediaType);
}
// A name the server makes up, in a folder UseStaticFiles does not serve; CreateNew never overwrites.
var name = RandomNumberGenerator.GetHexString(32, lowercase: true) + type.Extension;
await using (var stored = new FileStream(Path.Combine(folders.StorageDir, name), FileMode.CreateNew))
{
await stored.WriteAsync(content);
}
var url = "/avatars/" + name;
return Results.Created(url, new { url });
})
// Applies to each part of the multipart body, so the file itself may be at most MaxBytes.
.WithFormOptions(multipartBodyLengthLimit: MaxBytes);With IMPL=fixed, 13 of 13 tests pass.
The fix: serve it as a download
var storedName = new Regex("^[0-9a-f]{32}\\.(png|jpg)$");
app.MapGet("/avatars/{name}", (string name, HttpResponse response, AvatarFolders folders) =>
{
// Only names this code generates; anything else, ../ included, is simply not found.
var path = Path.Combine(folders.StorageDir, name);
if (!storedName.IsMatch(name) || !File.Exists(path))
{
return Results.NotFound();
}
// fileDownloadName makes Results.File send Content-Disposition: attachment; nosniff tells the
// browser not to second-guess the Content-Type.
response.Headers.XContentTypeOptions = "nosniff";
var mediaType = name.EndsWith(".png", StringComparison.Ordinal) ? Png.MediaType : Jpeg.MediaType;
return Results.File(File.ReadAllBytes(path), mediaType, fileDownloadName: name);
});With IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
string[] allowedTypes = ["image/png", "image/jpeg"];
const long maxBytes = 512 * 1024;
app.MapPost("/avatars", async (HttpRequest request, AvatarFolders folders) =>
{
var file = (await request.ReadFormAsync()).Files.GetFile("avatar");
if (file is null || file.FileName.Length == 0)
{
return Results.BadRequest(new { error = "attach the image in the \"avatar\" field" });
}
if (!allowedTypes.Contains(file.ContentType))
{
return Results.Json(new { error = "avatars must be PNG or JPEG images" }, statusCode: StatusCodes.Status415UnsupportedMediaType);
}
if (file.Length > maxBytes)
{
return Results.Json(new { error = "avatars must be at most 512 KiB" }, statusCode: StatusCodes.Status413PayloadTooLarge);
}
var name = Guid.NewGuid().ToString("N") + Path.GetExtension(file.FileName).ToLowerInvariant();
await using (var stored = File.Create(Path.Combine(folders.WebRoot, "uploads", name)))
{
await file.CopyToAsync(stored);
}
var url = "/uploads/" + name;
return Results.Created(url, new { url });
});With IMPL=wrong, 3 of 13 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: what an attacker sends instead of a picture.</summary>
public sealed class ExploitTests(AvatarsApiFactory factory) : AvatarApiTests(factory)
{
[Fact]
public async Task HtmlPageWithAScriptIsRefused() =>
await AssertRefused(await UploadAsync("profile.html", "text/html", Seed.Html), HttpStatusCode.UnsupportedMediaType);
[Fact]
public async Task SvgWithAScriptIsRefused() =>
await AssertRefused(await UploadAsync("avatar.svg", "image/svg+xml", Seed.Svg), HttpStatusCode.UnsupportedMediaType);
[Fact]
public async Task HtmlNamedPngAndDeclaredImagePngIsRefused() =>
await AssertRefused(await UploadAsync("avatar.png", "image/png", Seed.Html), HttpStatusCode.UnsupportedMediaType);
[Fact]
public async Task DoubleExtensionIsRefusedEvenWhenTheBytesStartLikeAPng() =>
await AssertRefused(await UploadAsync("avatar.png.html", "image/png", Seed.Polyglot), HttpStatusCode.UnsupportedMediaType);
[Fact]
public async Task PngThatIsAlsoAnHtmlPageIsServedOnlyAsAnImageDownload()
{
var upload = await UploadAsync("avatar.png", "image/png", Seed.Polyglot);
Assert.Equal(HttpStatusCode.Created, upload.Status);
using var served = await GetAsync(upload.Url!);
Assert.Equal("image/png", served.Content.Headers.ContentType?.MediaType);
Assert.Equal("nosniff", served.Headers.TryGetValues("X-Content-Type-Options", out var values) ? values.Single() : null);
Assert.Equal("attachment", served.Content.Headers.ContentDisposition?.DispositionType ?? "inline");
}
[Fact]
public async Task DotDotSlashInTheFileNameCannotPutTheFileOutsideTheUploadFolder()
{
await UploadAsync("../escaped.png", "image/png", Seed.Png);
Assert.Empty(FilesOutsideUploadFolders());
}
[Fact]
public async Task AbsoluteFileNameCannotPutTheFileOutsideTheUploadFolder()
{
await UploadAsync(Path.Combine(Factory.TempDir, "escaped.png"), "image/png", Seed.Png);
Assert.Empty(FilesOutsideUploadFolders());
}
[Fact]
public async Task FileOneByteOverTheLimitIsRefusedWith413() =>
await AssertRefused(await UploadAsync("avatar.png", "image/png", PadTo(Seed.Png, Seed.LimitBytes + 1)), HttpStatusCode.RequestEntityTooLarge);
[Fact]
public async Task UploadCannotReplaceAnotherUsersFileByReusingItsName()
{
var alice = await UploadAsync("avatar.png", "image/png", Seed.Png);
var mallory = await UploadAsync("avatar.png", "image/png", Seed.OtherPng);
Assert.Equal(HttpStatusCode.Created, alice.Status);
Assert.Equal(HttpStatusCode.Created, mallory.Status);
using var served = await GetAsync(alice.Url!);
// Alice's URL must still serve her own image.
Assert.Equal(Seed.Png, await served.Content.ReadAsByteArrayAsync(Cancel));
}
}- IFormFile.FileName is the client's string. ASP.NET Core passed ../escaped.png and an absolute path through in this card's tests, and Path.Combine() returns an absolute argument as it is, so the vulnerable File.Create() wrote outside the upload folder. Microsoft's guidance is to use the name only for display and logging, HTML-encoded.
- IFormFile.ContentType is the Content-Type of the multipart part, which the client writes. A script can send image/png with an HTML body, which is how avatar.png.html gets past the wrong implementation.
- UseStaticFiles serves anything in the web root with a Content-Type from its extension, inline and without X-Content-Type-Options, so {random}.html is served as text/html.
- Kestrel caps request bodies at 30,000,000 bytes (about 28.6 MB) and FormOptions.MultipartBodyLengthLimit at 134,217,728 bytes (128 MB) by default, and form files over 64 KB are buffered to temporary files. Checking IFormFile.Length alone happens after that buffering.
- On probes, .WithFormOptions(multipartBodyLengthLimit: ...) stopped ReadFormAsync under both Kestrel and TestServer, while [RequestSizeLimit] metadata on a minimal API endpoint was enforced by Kestrel but ignored by TestServer, so a WebApplicationFactory test of it would pass without the limit ever running.
- Binding IFormFile as a minimal API parameter makes the endpoint require antiforgery validation, because minimal APIs that accept form data validate antiforgery tokens by default. This card reads the form with ReadFormAsync and has no sign-in. Behind cookie authentication keep CSRF protection on rather than calling DisableAntiforgery() (see the CSRF card).
- Path.GetExtension() returns only the last extension (".html" for avatar.png.html), which is what the fixed check needs. Checks such as fileName.Contains(".png") accept avatar.png.html.
- A file signature proves how a file starts, not that it is harmless: the polyglot test is a valid PNG that is also a working page. The serving headers are what keep it from rendering; re-encode images with a maintained library if you also need to strip trailing data and metadata.
- Content-Disposition: attachment does not stop an <img> element from showing the avatar; it makes a direct visit to the URL download the file instead of rendering it.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/csharp-aspnetcore does this for you).
- Against your own endpoint, upload an HTML file named avatar.png with Content-Type image/png and a .svg with a <script> element; both must be refused with nothing stored. Open the URL of an accepted avatar and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
- Do not count on static analysis here: the Semgrep rules and CA3003 above reported nothing on any implementation, and CodeQL's only CWE-434 query for C# targets ASP.NET Web Forms. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/default, p/security-audit, p/owasp-top-ten | No findings in the seven source files. The semgrep-rules repository (develop at 9bbf021) has no rule for IFormFile.FileName or IFormFile.ContentType.Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/default --config p/security-audit --config p/owasp-top-ten src |
| Semgrep | csharp.lang.security.filesystem.unsafe-path-combine.unsafe-path-combine | No findings. Its sources are string parameters of a public method passed to Path.Combine, and its sinks are File methods whose names start with Read or Write; the vulnerable code passes file.FileName, a property, to File.Create inside a lambda. An earlier draft of this card, with the serving code in a public Serve(string name) method, got one finding there: on the fixed code, whose name is checked against a pattern the rule does not recognise.Checked: Semgrep 1.180.0 (CE); semgrep --config r/csharp.lang.security.filesystem.unsafe-path-combine.unsafe-path-combine src (Ran 1 rule on 5 files) |
| CodeQL | cs/path-injection | From flowsources/Remote.qll: parameters of minimal API route handlers and calls on HttpRequest are remote sources, and properties of those objects carry taint. No model covers IFormFileCollection.GetFile(), so the flow from ReadFormAsync() to file.FileName may be lost before File.Create(Path.Combine(...)); with an IFormFile handler or action parameter the file name would be a source directly. Not confirmed without running CodeQL.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | cs/web/file-upload | Tagged CWE-434, but it only matches HtmlInputFile.PostedFile from ASP.NET Web Forms (System.Web.UI.HtmlControls), so it cannot fire on ASP.NET Core.Checked: github/codeql main at b5b8165; not executed |
| .NET code analysis | CA3003Review code for file path injection vulnerabilities | Off by default. Enabled, it reported nothing on this card. On a probe it flagged File.Create and File.WriteAllBytes with Path.Combine(dir, avatar.FileName) in MVC controller actions that take IFormFile, and nothing for the same code in minimal API endpoints, whether the handler binds IFormFile or calls ReadFormAsync.Checked: .NET SDK 10.0.107 with -p:AnalysisModeSecurity=All |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| Microsoft.AspNetCore.App (IFormFile, static files, Results.File; built in) | .NET 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.1 (Microsoft Testing Platform v2) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/csharp-aspnetcorefolder.
dotnet restore tests/Avatars.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Avatars.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Avatars.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Avatars.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs file-upload/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- mvcTesting 10.0.12
- xunitV3 4.0.1
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 9 of 13 tests fail (9 exploit tests); wrong: 3 of 13 tests fail (3 exploit tests)
- Test results
- IMPL=fixed 13/13 pass; IMPL=vulnerable 9/13 fail, all exploit tests (HTML, SVG, HTML named avatar.png, avatar.png.html, the polyglot's headers, ../ and the absolute name both writing outside the upload folder, one byte over the limit, the reused name); IMPL=wrong 3/13 fail, all exploit tests (HTML named avatar.png, avatar.png.html served as text/html, the polyglot's headers). The tests run on TestServer; a manual run on Kestrel showed the fixed endpoint's headers: image/png, X-Content-Type-Options: nosniff and Content-Disposition: attachment with filename and filename*. The same results came from a copy restored with empty NuGet folders (75 MB downloaded) and run in a no-network sandbox.
- Dependency freshness
- Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) and xunit.v3 4.0.1 (2026-09-12) match the other C# cards; both projects restore with lock files in locked mode. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
- Scanners
- Semgrep 1.180.0 from a throwaway venv (pip install semgrep==1.180.0), not a card dependency; CA3003 from the .NET 10 SDK; CodeQL read from source, not executed.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP File Upload Cheat Sheet
- OWASP ASVS 5.0.0, V5 File Handling
- Microsoft Learn: Upload files in ASP.NET Core (security considerations, limits)
- Microsoft Learn: Antiforgery with minimal APIs
- .NET API: FormOptions.MultipartBodyLengthLimit
- .NET API: RandomNumberGenerator.GetHexString
- WHATWG MIME Sniffing: matching an image type pattern
- MDN: X-Content-Type-Options
- Microsoft Learn: CA3003
Attack patterns: