UploadGuard 0.1.0-preview.2
dotnet add package UploadGuard --version 0.1.0-preview.2
NuGet\Install-Package UploadGuard -Version 0.1.0-preview.2
<PackageReference Include="UploadGuard" Version="0.1.0-preview.2" />
<PackageVersion Include="UploadGuard" Version="0.1.0-preview.2" />
<PackageReference Include="UploadGuard" />
paket add UploadGuard --version 0.1.0-preview.2
#r "nuget: UploadGuard, 0.1.0-preview.2"
#:package UploadGuard@0.1.0-preview.2
#addin nuget:?package=UploadGuard&version=0.1.0-preview.2&prerelease
#tool nuget:?package=UploadGuard&version=0.1.0-preview.2&prerelease
UploadGuard
Secure file uploads for ASP.NET Core, from one declarative policy. UploadGuard detects the real file type from its bytes, checks it against an allow-list, and then looks inside: zip bombs, zip slip, Office macros, PDF JavaScript, SVG scripts and image polyglots. It can also scan with ClamAV, re-encode images with PixelForge, and store files under random names outside the web root.
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"); // 400 ValidationProblem before the handler runs
MIT. No dependencies beyond ASP.NET Core in the core package. .NET 10 and later.
Why
Most upload code checks the extension, and sometimes the first few bytes. OWASP says 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 .docx with a VBA project, or a 20 KB ZIP that unpacks to 20 GB all pass a magic-byte check.
The table below runs the same files through each library, used the way apps usually use them: accept the file when the detected extension equals the file's extension. You can reproduce it with dotnet run -c Release --project benchmarks/UploadGuard.Benchmarks -- --compare. Every file is generated by Samples.cs.
| File | Expected | Mime-Detective 25.8.1 | File.TypeChecker 4.4.0 | UploadGuard |
|---|---|---|---|---|
| JPEG photo | accept | ✅ accepted (detected jpg) | ✅ accepted (detected jpg) | ✅ accepted (detected jpg) |
| Word document | accept | ❌ rejected (detected zip) | ✅ accepted (detected docx) | ✅ accepted (detected docx) |
| CSV export | accept | ❌ rejected (not recognized) | ❌ rejected (not recognized) | ✅ accepted (detected csv) |
| JPEG with PHP appended | reject | ❌ accepted (detected jpg) | ❌ accepted (detected jpg) | ✅ rejected (ActiveContent) |
| GIF/HTML polyglot | reject | ❌ accepted (detected gif) | ❌ accepted (detected gif) | ✅ rejected (ActiveContent) |
PNG with <script> appended |
reject | ❌ accepted (detected png) | ❌ accepted (detected png) | ✅ rejected (ActiveContent) |
| Macro-enabled Word renamed .docx | reject | ✅ rejected (detected zip) | ❌ accepted (detected docx) | ✅ rejected (ExtensionMismatch) |
| ZIP bomb (20 MB of zeros) | reject | ❌ accepted (detected zip) | ✅ rejected (detected docx) | ✅ rejected (ArchiveTooLarge) |
ZIP slip (../ entry) |
reject | ❌ accepted (detected zip) | ✅ rejected (detected docx) | ✅ rejected (UnsafeArchive) |
| PDF with JavaScript | reject | ❌ accepted (detected pdf) | ❌ accepted (detected pdf) | ✅ rejected (ActiveContent) |
| PDF with JavaScript in an object stream | reject | ❌ accepted (detected pdf) | ❌ accepted (detected pdf) | ✅ 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 (detected jpg) | ❌ accepted (detected jpg) | ✅ rejected (InvalidFileName) |
| Correct | 4/14 | 6/14 | 14/14 |
Some ✅ in the other columns are right for the wrong reason: File.TypeChecker reports these ZIP files as docx, and Mime-Detective reports both Word files as zip. Both libraries are good at what they do, which is signature matching; UploadGuard is the layer around it.
Install
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
Policies
A policy is an allow-list plus limits. Everything else is rejected.
builder.Services.AddUploadGuard(o =>
{
o.AddPolicy("avatars", p => p
.AllowImages() // JPEG, PNG, GIF, WebP
.MaxSize(5 * 1024 * 1024)); // default 10 MB
o.AddPolicy("imports", p => p
.Allow(FileTypes.Csv, FileTypes.Xlsx)
.LimitArchives(maxEntries: 1_000, maxUncompressedSize: 50 * 1024 * 1024, maxCompressionRatio: 50));
});
Policies are built at startup: a policy without types, a duplicate name, or WithUploadPolicy("typo") fails when the app starts, not on the first upload.
Raise ASP.NET Core's own limits too when you allow large files (KestrelServerLimits.MaxRequestBodySize, FormOptions.MultipartBodyLengthLimit). They run before UploadGuard and reject oversized requests cheaply.
What runs, in order
The first failing check decides the result. UploadResult.Error.Code tells you which one.
| # | Check | Error code |
|---|---|---|
| 1 | The file is not empty | Empty |
| 2 | The size is within MaxSize |
TooLarge |
| 3 | The file name has no path, colon, control or invisible formatting characters (bidi overrides, zero-width), leading dot or hyphen, consecutive dots, trailing dot or space, Windows device name, or executable inner extension (shell.php.jpg), and is at most 255 characters |
InvalidFileName |
| 4 | The content is a known type. Detection uses the bytes only. | UnknownType |
| 5 | The extension belongs to the detected type. HTML is sniffed the way browsers do, so a page cannot pass as .txt or .csv. |
ExtensionMismatch |
| 6 | The type is in the policy's allow-list | TypeNotAllowed |
| 7 | The Content-Type header is empty, application/octet-stream, or a known media type for the detected type |
ContentTypeMismatch |
| 8 | Type-specific content checks (next table) | see below |
| 9 | Every registered IUploadScanner, such as ClamAV. When a scanner fails or times out, the upload is rejected. |
Malware, ScanFailed |
| Type | Content check | Error code |
|---|---|---|
| ZIP, DOCX/XLSX/PPTX, ODT/ODS/ODP | Entry count, total unpacked size and compression ratio, read from the central directory without unpacking. Encrypted entries. Paths with .., /, \ or a drive letter. |
ArchiveTooLarge, UnsafeArchive, Corrupt |
| DOCX/XLSX/PPTX | A VBA project makes the file DOCM/XLSM/PPTM, so a renamed macro document fails step 5 | ExtensionMismatch |
| ODT/ODS/ODP | Basic/ or Scripts/ macro folders |
ActiveContent |
| DOC/XLS/PPT | A VBA project in the compound file | ActiveContent |
| GZIP | Unpacked size, stopping at the limit | ArchiveTooLarge |
/JavaScript, /JS, /Launch, /EmbeddedFile, /RichMedia, /XFA, also with #xx name escapes and inside FlateDecode streams (object streams) |
ActiveContent |
|
| SVG | <script>, <foreignObject>, <iframe>, <embed>, <object>, on* event handlers, javascript:/vbscript:/data:text/html URLs, xml-stylesheet. The DTD is never processed. |
ActiveContent, Corrupt |
| Images | <script, <?php, <html, <iframe, <body or javascript: anywhere in the file (polyglots) |
ActiveContent |
Detected types
| Group | Types |
|---|---|
| Images | JPEG, PNG, GIF, WebP, BMP, TIFF, ICO, HEIC/HEIF, AVIF, SVG |
| Documents | PDF, DOCX, DOCM, XLSX, XLSM, PPTX, PPTM, DOC, XLS, PPT, ODT, ODS, ODP, RTF, plain text, CSV, XML, HTML |
| Archives | ZIP, GZIP, 7z, RAR, TAR |
| Audio and video | MP3, WAV, Ogg, FLAC, M4A, MP4, MOV, WebM, MKV, AVI |
| Executables | Windows PE, ELF, Mach-O, shell scripts with #!. Detected so the error names them. |
Text is valid UTF-8 (or UTF-16 with a byte order mark) without control characters. CSV and plain text have the same content, so the extension picks one. 7z, RAR and TAR are detected but their contents are not inspected: prefer ZIP if you accept archives.
Minimal APIs
WithUploadPolicy validates every file in the form before the handler runs. Invalid files get a 400 validation problem with one error per form field:
app.MapPost("/import", (IFormFile file) => Import(file)).WithUploadPolicy("imports");
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { "file": ["The file has the extension '.csv', but the content was detected as HTML document."] }
}
It works on route groups too: app.MapGroup("/uploads").WithUploadPolicy("documents").
MVC
[HttpPost("avatar")]
[UploadPolicy("avatars")]
public IActionResult Upload(IFormFile file) => ...;
Errors are added to ModelState and returned as a ValidationProblemDetails.
Validating, saving and any stream
Inject UploadPipeline when you need the result:
UploadResult result = await uploads.ValidateAsync(file, "cv"); // checks only
UploadResult saved = await uploads.SaveAsync(file, "avatars"); // checks, transforms, stores
result.FileType is the detected type, result.Error is null when valid, and saved.StoredName is the generated name. Error messages never contain internal details, so you can return them to the client.
Both methods also take a Stream, a file name and a Content-Type, for uploads that do not come from IFormFile. The stream must be seekable because it is read more than once; IFormFile streams already are.
Antivirus with ClamAV
builder.Services.AddUploadGuardClamAV(o => builder.Configuration.GetSection("ClamAV").Bind(o)); // Host, Port, Timeout
builder.Services.AddHealthChecks().AddClamAV();
Every file that passes the built-in checks is streamed to clamd with INSTREAM. Infected files are rejected with Malware and the signature name is logged, not returned to the client. If clamd is down or slower than Timeout (30 s by default), the upload is rejected with ScanFailed: UploadGuard fails closed. Set clamd's StreamMaxLength to at least your largest MaxSize; clamd refuses longer streams, which also ends in ScanFailed.
Run clamd locally:
docker run -d -p 3310:3310 clamav/clamav:stable
Or in an Aspire AppHost:
var clamav = builder.AddContainer("clamav", "clamav/clamav", "stable")
.WithEndpoint(targetPort: 3310, name: "tcp");
builder.AddProject<Projects.Api>("api")
.WithEnvironment(context =>
{
var endpoint = clamav.GetEndpoint("tcp");
context.EnvironmentVariables["ClamAV__Host"] = endpoint.Property(EndpointProperty.Host);
context.EnvironmentVariables["ClamAV__Port"] = endpoint.Property(EndpointProperty.Port);
});
ClamAVClient can also be used on its own: PingAsync, GetVersionAsync and ScanAsync(stream). It replaces nClam for new code: async, cancellable, with timeouts and dependency injection.
UploadGuard.ClamAV is not affiliated with Cisco or the ClamAV project.
Re-encoding images with PixelForge
o.AddPolicy("avatars", p => p.AllowImages().ReencodeImages(maxPixels: 25_000_000, quality: 90));
SaveAsync decodes each image and encodes it again (content disarm and reconstruction). 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 checked from the header before any memory is allocated, which stops decompression bombs.
JPEG and WebP keep their format (lossless WebP stays lossless). PNG, GIF and BMP become PNG; a GIF keeps only its first frame. Other image types are rejected under this policy, and non-image types in the same policy are stored unchanged.
Storing and serving files
With StoragePath set, SaveAsync stores files with FileSystemUploadStorage:
- Names are 128 random bits plus the detected type's extension (
3f9c…e1.jpg). The client's file name is never used as a path. - The app refuses to start if
StoragePathis inside the web root. OpenReadAsyncandDeleteAsyncaccept only names it generated, so a name from a URL cannot traverse.
Register your own IUploadStorage for blob storage. When you serve files back, send them so 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);
});
Better still, serve user files from a separate domain. samples/UploadApi has a complete API.
Extending
IUploadScanner: register it in dependency injection. ThrowUploadRejectedExceptionto reject; any other exception rejects withScanFailed.IUploadTransformer: add it withpolicy.Transform(...). It runs inSaveAsyncafter validation.IUploadStorage: register it to replace the file system storage.
Observability
- Metric
uploadguard.uploads(meterUploadGuard), a counter tagged withuploadguard.policy,uploadguard.result(Acceptedor the error code) anduploadguard.file_type. Add it with.AddMeter("UploadGuard")in OpenTelemetry. - Rejections are logged at
Informationwith the policy and reason; scanner failures atError.
Performance
Time to accept one valid file (BenchmarkDotNet ShortRun, AMD Ryzen 5 5500U, .NET 10.0.11). The other libraries only match signatures; UploadGuard runs the whole policy, including the content checks for that type, so the numbers are not like for like.
| 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 |
UploadGuard reads files in 64 KB chunks and never loads them whole. PDF streams are inflated to look for hidden JavaScript, which costs time in proportion to the stream size, up to LimitArchives(maxUncompressedSize).
Limits
UploadGuard lowers risk; it does not make untrusted files safe to open. Read docs/security.md for the threat model, the mapping to the OWASP File Upload Cheat Sheet, and what is not covered. In short:
- The PDF and legacy Office checks are byte scans, not parsers. Pair them with antivirus, and never render user PDFs inline on your own origin.
- Only images can be rebuilt (with UploadGuard.PixelForge). Documents and archives are stored as uploaded.
- Nested archives are not unpacked, and 7z, RAR and TAR contents are not inspected.
- Authentication, authorization, CSRF protection and rate limiting are your app's job.
License
MIT. Security issues: see SECURITY.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (2)
Showing the top 2 NuGet packages that depend on UploadGuard:
| Package | Downloads |
|---|---|
|
UploadGuard.ClamAV
Async ClamAV (clamd) client and virus scanner for UploadGuard: INSTREAM, PING and VERSION, dependency injection and an ASP.NET Core health check. Not affiliated with Cisco or the ClamAV project. |
|
|
UploadGuard.PixelForge
Image re-encoding (content disarm and reconstruction) for UploadGuard, built on PixelForge: strips EXIF and GPS, removes appended and polyglot payloads, and rejects decompression bombs. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-preview.2 | 65 | 11/7/2026 |
| 0.1.0-preview.1 | 65 | 11/7/2026 |