Most ASP.NET Core apps accept files from users somewhere: avatars, CVs, CSV imports. The validation is usually a few lines. Compare the extension with a list, maybe read the first bytes with a signature library, and save the file. The OWASP File Upload Cheat Sheet is blunt about signature checks: "This should not be used on its own, as bypassing it is pretty common and easy."
A JPEG header followed by <?php, a Word document with a VBA project inside, and a ZIP that unpacks to a hundred times its size all start with the right magic bytes. I wanted one place that does the rest of the work, so I built UploadGuard. It's MIT licensed, it targets .NET 10, and the first preview went up on NuGet on 7 November 2026. This post is about what it checks, in what order, and the parts I deliberately left out.
One policy per kind of upload
You describe each kind of upload once, as a named allow-list with limits:
builder.Services.AddUploadGuard(o =>
{
o.StoragePath = "/var/app/uploads";
o.AddPolicy("avatars", p => p.AllowImages().MaxSize(5 * 1024 * 1024).ReencodeImages()); // UploadGuard.PixelForge
o.AddPolicy("cv", p => p.Allow(FileTypes.Pdf, FileTypes.Docx).MaxSize(10 * 1024 * 1024));
});
builder.Services.AddUploadGuardClamAV(o => o.Host = "clamav"); // UploadGuard.ClamAV, optional
app.MapPost("/avatar", async (IFormFile file, UploadPipeline uploads) =>
{
var result = await uploads.SaveAsync(file, "avatars");
return result.IsValid ? Results.Ok(result.StoredName) : Results.BadRequest(result.Error);
});
app.MapPost("/cv", (IFormFile file) => ...).WithUploadPolicy("cv");
WithUploadPolicy is an endpoint filter: every file in the form is checked before the handler runs, and a bad one gets a 400 validation problem with one error per form field. MVC has the same thing as an [UploadPolicy("cv")] attribute.
Mistakes in the configuration show up at startup. AddUploadGuard runs your configuration eagerly instead of on first use, and the endpoint filter looks its policy up while the endpoint is being built. A policy without types, a duplicate name or WithUploadPolicy("typo") stops the app from starting, rather than failing on the first real upload.
Fourteen files
To see where signature matching stops, I ran the same 14 files through two popular signature libraries and through UploadGuard, used the way apps usually use them: accept the file when the detected extension equals the file's extension. Every file is generated by the test suite, and the comparison runs with dotnet run -c Release --project benchmarks/UploadGuard.Benchmarks -- --compare.
| File | Expected | Mime-Detective 25.8.1 | File.TypeChecker 4.4.0 | UploadGuard |
|---|---|---|---|---|
| JPEG photo | accept | ✅ accepted | ✅ accepted | ✅ accepted |
| Word document | accept | ❌ rejected (detected zip) | ✅ accepted | ✅ accepted |
| CSV export | accept | ❌ rejected (not recognized) | ❌ rejected (not recognized) | ✅ accepted |
| JPEG with PHP appended | reject | ❌ accepted | ❌ accepted | ✅ rejected (ActiveContent) |
| GIF/HTML polyglot | reject | ❌ accepted | ❌ accepted | ✅ rejected (ActiveContent) |
PNG with <script> appended |
reject | ❌ accepted | ❌ accepted | ✅ rejected (ActiveContent) |
| Macro-enabled Word renamed .docx | reject | ✅ rejected (detected zip) | ❌ accepted | ✅ rejected (ExtensionMismatch) |
| ZIP bomb (20 MB of zeros) | reject | ❌ accepted | ✅ rejected (detected docx) | ✅ rejected (ArchiveTooLarge) |
ZIP slip (../ entry) |
reject | ❌ accepted | ✅ rejected (detected docx) | ✅ rejected (UnsafeArchive) |
| PDF with JavaScript | reject | ❌ accepted | ❌ accepted | ✅ rejected (ActiveContent) |
| PDF with JavaScript in an object stream | reject | ❌ accepted | ❌ accepted | ✅ rejected (ActiveContent) |
SVG with <script>
|
reject | ✅ rejected (detected xml) | ✅ rejected (detected xml) | ✅ rejected (ActiveContent) |
| HTML page named .csv | reject | ✅ rejected (not recognized) | ✅ rejected (not recognized) | ✅ rejected (ExtensionMismatch) |
File name shell.php.jpg
|
reject | ❌ accepted | ❌ accepted | ✅ rejected (InvalidFileName) |
| Correct | 4/14 | 6/14 | 14/14 |
Some of the ✅ in the first two columns are right for the wrong reason. File.TypeChecker reports the bomb and the zip slip file as docx, and Mime-Detective reports both Word files as zip. That isn't a criticism: both libraries are good at signature matching, which is what they're for. UploadGuard is the layer around it.
Nine checks, cheapest first
Every upload goes through the same checks, and the first one that fails decides the result:
| # | Check | Error code |
|---|---|---|
| 1 | The file isn't empty | Empty |
| 2 | The size is within the policy's MaxSize (10 MB by default) |
TooLarge |
| 3 | The file name is safe | InvalidFileName |
| 4 | The content is a known type, detected from the bytes only | UnknownType |
| 5 | The extension belongs to the detected type | ExtensionMismatch |
| 6 | The type is on the policy's allow-list | TypeNotAllowed |
| 7 | The Content-Type header is empty, application/octet-stream or a media type of the detected type |
ContentTypeMismatch |
| 8 | Type-specific content checks |
ArchiveTooLarge, UnsafeArchive, ActiveContent, Corrupt
|
| 9 | Every registered scanner, such as ClamAV |
Malware, ScanFailed
|
The order is deliberate. Size is checked before anything reads the content, so every later scan is bounded by MaxSize. And because only one check fails, UploadResult.Error.Code names exactly one reason. That code also tags the uploadguard.uploads metric, so a dashboard can show which check is rejecting what. The error messages never contain internal details, so they're safe to return to the client.
The file name rules in step 3 aren't about storage, because stored files never use the client's name. They catch names that only an attacker sends: paths, colons (file.asp::$DATA), control and invisible bidi characters, Windows device names like CON.jpg, trailing dots, and an executable extension in the middle, as in shell.php.jpg, which a misconfigured Apache will run as PHP.
The type comes from the bytes
The detector never looks at the extension or the Content-Type. It reads the header and recognizes 47 types, from JPEG and PDF to Office, OpenDocument, audio, video and executables. Some formats can't be told apart by content: CSV and plain text are the same bytes, and DOC, XLS and PPT share the compound file header. For those the detector returns several candidates, and the extension chooses between them:
var candidates = await FileDetector.DetectAsync(content, cancellationToken);
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var type = Array.Find(candidates, c => c.Extensions.Contains(extension));
// null here is ExtensionMismatch
Text gets the most care, because that's where HTML hides. A file counts as text when the whole thing decodes as strict UTF-8 (or UTF-16 with a byte order mark) without control characters. Then it's classified the way browsers sniff an unknown type, following the WHATWG MIME Sniffing Standard: if it starts with <!DOCTYPE HTML, <html, <script, <iframe, <body or one of the other tags in that list, followed by a space or >, it's HTML. That's how the "HTML page named .csv" row gets rejected. A browser that sniffs the file will render it as a page, so it can't be accepted as a CSV.
Office files needed one more step. A .docx and a .docm are both ZIP files with the same layout. Office decides by the content type of the main part in [Content_Types].xml, and a VBA project makes the file macro-enabled. UploadGuard reads the same things:
var macros = Has("macroEnabled") || Has("vbaProject")
|| zip.Entries.Any(e => e.FullName.EndsWith("vbaProject.bin", StringComparison.OrdinalIgnoreCase));
if (Has("wordprocessingml.document.main+xml") || Has("ms-word.document.macroEnabled.main+xml"))
{
return macros ? FileTypes.Docm : FileTypes.Docx;
}
So a macro document renamed to .docx is detected as DOCM and fails step 5. No macro scanner is involved. For the older binary formats, DOC, XLS and PPT, step 8 searches for the _VBA_PROJECT directory entry instead, and for OpenDocument it looks for Basic/ and Scripts/ folders.
Archives without unpacking them
ZIP-based files (plain ZIP, OOXML and OpenDocument) are checked from the central directory, the index at the end of the file that lists every entry with its sizes. Nothing is inflated:
if (zip.Entries.Count > policy.MaxArchiveEntries)
return TooLarge(...);
long total = 0;
foreach (var entry in zip.Entries)
{
if (entry.IsEncrypted)
return new(UploadErrorCode.UnsafeArchive, "The archive has encrypted entries, which cannot be inspected or scanned.");
if (!IsSafePath(entry.FullName))
return new(UploadErrorCode.UnsafeArchive, "The archive has an entry whose path leaves the extraction folder.");
if (entry.Length > policy.MaxUncompressedBytes - total)
return TooLarge(...);
total += entry.Length;
}
return total > policy.MaxCompressionRatio * stream.Length ? TooLarge(...) : null;
The defaults are 10,000 entries, 256 MB unpacked and a ratio of 100, and LimitArchives(...) changes them per policy. IsSafePath is the zip slip check: a leading slash, a drive letter, a NUL or a .. segment, with either kind of slash, rejects the file.
Trusting the declared sizes looks wrong for a bomb check, since an attacker writes them. It holds because .NET's ZipArchive never inflates an entry past its declared size, and other extractors fail on the size and CRC mismatch. So code that unpacks with them can't be bombed by a lie these checks accepted. A custom inflater that ignores the declared size isn't protected, and the security notes say so.
GZIP has no central directory, so there it's the other way round: the stream is inflated into a buffer that is thrown away, and the check stops as soon as the output passes the limit.
PDFs: a byte scan with two extra steps
UploadGuard doesn't parse PDFs. It looks for the names that make a PDF do something when it's opened: /JavaScript, /JS, /Launch, /EmbeddedFile, /RichMedia and /XFA. A plain search for those is easy to get around, in two ways.
First, a PDF name can write any character as # and two hex digits, so /J#61vaScript is /JavaScript. The scanner decodes these escapes before it matches.
Second, since PDF 1.5 objects can be stored inside compressed object streams, where a byte search only sees deflate data. So the inspector finds every stream keyword, checks whether a zlib header follows it, and if so inflates the stream and scans the result:
// "stream", then CRLF or LF, then a zlib header: compression method 8 (deflate),
// and the two header bytes read as CMF * 256 + FLG are a multiple of 31 (RFC 1950).
var cmf = window[data];
var flg = window[data + 1];
return (cmf & 0x0F) == 8 && ((cmf << 8) | flg) % 31 == 0 ? from + data : null;
All streams in a file share one budget, the same maxUncompressedSize as archives, so a PDF can't be used as a decompression bomb against the checker itself. This is the "JavaScript in an object stream" row in the table, and it's also the slowest path: a PDF with a 200 KB compressed stream takes about 225 µs.
It's still a scan and not a parser. LZW, ASCIIHex and encrypted streams aren't decoded. Pair it with antivirus, and never render user PDFs inline on your own origin.
Images, SVG and polyglots
A file can be valid in two formats at once. A GIF can also parse as an HTML page, and a JPEG can carry PHP after its last marker. Every accepted image is searched for <script, <?php, <html, <iframe, <body and javascript:, ignoring case. The patterns are long enough that random pixel data practically never contains them.
All the byte searches stream the file in 64 KB chunks instead of loading it. The last few bytes of each chunk are carried into the next one, as many as the longest pattern minus one, so a match across a chunk boundary isn't missed. For PDF names the overlap is three times longer, because each #xx escape shrinks to one byte when it's decoded.
SVG is XML, so it's read with XmlReader, with the DTD ignored and never resolved. A file is rejected for script-like elements (script, foreignObject, iframe, embed, object), on* event handler attributes, an xml-stylesheet instruction, or a script URL. Browsers ignore tabs and line breaks inside a URL scheme, so javascript: with a tab in the middle still runs, and the check compares without them:
private static bool HasScriptUrl(string value)
{
var compact = string.Concat(value.Where(c => c > ' '));
return ScriptUrls.Any(url => compact.Contains(url, StringComparison.OrdinalIgnoreCase));
}
The markup scan only finds payloads it knows about. Re-encoding removes all of them. With UploadGuard.PixelForge, SaveAsync decodes each image with PixelForge and encodes it again, so the stored file contains only pixels: EXIF and GPS data, comments, appended bytes and polyglot payloads are gone. The image is rotated upright first, because the orientation tag is dropped too. The pixel limit is read from the header before any memory is allocated, which stops decompression bombs such as a tiny PNG that claims to be 50000×50000. JPEG and WebP keep their format, and lossless WebP stays lossless. PNG, GIF and BMP become PNG.
Antivirus that fails closed
UploadGuard.ClamAV streams every file that passed the built-in checks to clamd. The INSTREAM command is small enough to implement directly: send zINSTREAM\0, then the content in chunks, each prefixed with its length as a 4-byte unsigned integer in network byte order, then a zero-length chunk. The z prefix makes clamd end its reply with a NUL byte.
private static async Task StreamChunksAsync(Stream content, NetworkStream network, CancellationToken cancellationToken)
{
var buffer = new byte[4 + ChunkSize];
int read;
while ((read = await content.ReadAsync(buffer.AsMemory(4, ChunkSize), cancellationToken)) > 0)
{
BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)read);
await network.WriteAsync(buffer.AsMemory(0, 4 + read), cancellationToken);
}
await network.WriteAsync(new byte[4], cancellationToken);
}
The more important decision is what happens when the scanner can't answer. If clamd is down, slower than the timeout (30 seconds by default), or refuses a stream longer than its StreamMaxLength, the upload is rejected with ScanFailed. A scanner that can't give a verdict is treated as a scanner that said no:
catch (UploadRejectedException e)
{
return result with { Error = e.Error };
}
catch (Exception e) when (!cancellationToken.IsCancellationRequested)
{
LogScanFailed(_logger, e, scanner.GetType().Name);
return result with { Error = new(UploadErrorCode.ScanFailed, "The file could not be scanned for malware.") };
}
The signature name of an infected file goes to the log, not to the client. ClamAVClient also works on its own, with PingAsync, GetVersionAsync and ScanAsync, and there's a health check for it.
Storing and serving files
With a StoragePath, accepted files are saved under 128 random bits plus the detected type's extension, so the client's file name is never used as a path. The app refuses to start if that folder is inside the web root, where files could be served or executed directly. Reading and deleting accept only names in the generated format, so a name taken from a URL can't traverse out of the folder.
Serving files back is still your code, and it should make sure a browser never runs them:
app.MapGet("/files/{name}", async (string name, IUploadStorage storage, HttpContext http) =>
{
var content = await storage.OpenReadAsync(name); // throws ArgumentException for foreign names
http.Response.Headers.XContentTypeOptions = "nosniff";
http.Response.Headers.ContentSecurityPolicy = "default-src 'none'; sandbox";
return Results.File(content, "application/octet-stream", fileDownloadName: name);
});
Serving user files from a separate domain is better still. For blob storage, register your own IUploadStorage.
How fast it is
Time to accept one valid file, measured with BenchmarkDotNet on an AMD Ryzen 5 5500U and .NET 10.0.11:
| File | UploadGuard | Mime-Detective | File.TypeChecker |
|---|---|---|---|
| 1920×1080 JPEG | 9.7 µs | 60.1 µs | 5.4 µs |
| DOCX | 9.9 µs | 13.7 µs | 7.6 µs |
| PDF with a 200 KB compressed stream | 225 µs | 8.6 µs | 7.8 µs |
The comparison isn't like for like. The other two libraries only match signatures, while UploadGuard runs the whole policy, including the content checks for that type. The PDF row is the inflation from the previous section, and its cost grows with the size of the streams, up to the policy's limit.
What it doesn't cover
UploadGuard lowers the risk of accepting a file. It doesn't make an untrusted file safe to open in every program, and I'd rather be clear about where it stops:
- The PDF and legacy Office checks are byte scans, not parsers. PowerPoint 97 can store VBA in places the
_VBA_PROJECTsearch doesn't see. - Archives are inspected one level deep. A ZIP inside a ZIP isn't unpacked, and 7z, RAR and TAR are recognized but their contents aren't inspected.
- Only images can be rebuilt. Documents, archives and media are stored as uploaded, and their content can still exploit a vulnerable viewer.
- Text formats aren't validated beyond their encoding. CSV formula injection and XML external entities are problems for the code that reads the file.
- Authentication, authorization, CSRF protection and rate limiting stay your app's job.
The security notes map every recommendation of the OWASP cheat sheet to either a feature or "your app", and list these gaps in more detail.
Trying it
dotnet add package UploadGuard --prerelease
dotnet add package UploadGuard.ClamAV --prerelease # optional: virus scanning with clamd
dotnet add package UploadGuard.PixelForge --prerelease # optional: image re-encoding
It's a preview, so the API can still change. If a file gets through that shouldn't, or a real file gets rejected, please open an issue. For anything that looks like a vulnerability, the security policy says how to report it privately.
References
- GrantTotinov/UploadGuard on GitHub: README and security model.
- NuGet: UploadGuard, UploadGuard.ClamAV and UploadGuard.PixelForge.
- OWASP, File Upload Cheat Sheet.
- WHATWG, MIME Sniffing Standard.
- Adobe, PDF 32000-1:2008 (PDF 1.7), §7.3.5 Name Objects and §7.5.7 Object Streams.
- P. Deutsch and J-L. Gailly, RFC 1950: ZLIB Compressed Data Format Specification version 3.3, 1996.
- ClamAV, clamd(8) manual page, the
INSTREAMcommand. - The libraries in the comparison: Mime-Detective 25.8.1 and File.TypeChecker 4.4.0.
Top comments (0)