UploadGuard.ClamAV 0.1.0-preview.2

This is a prerelease version of UploadGuard.ClamAV.
dotnet add package UploadGuard.ClamAV --version 0.1.0-preview.2
                    
NuGet\Install-Package UploadGuard.ClamAV -Version 0.1.0-preview.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="UploadGuard.ClamAV" Version="0.1.0-preview.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UploadGuard.ClamAV" Version="0.1.0-preview.2" />
                    
Directory.Packages.props
<PackageReference Include="UploadGuard.ClamAV" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add UploadGuard.ClamAV --version 0.1.0-preview.2
                    
#r "nuget: UploadGuard.ClamAV, 0.1.0-preview.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package UploadGuard.ClamAV@0.1.0-preview.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=UploadGuard.ClamAV&version=0.1.0-preview.2&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=UploadGuard.ClamAV&version=0.1.0-preview.2&prerelease
                    
Install as a Cake Tool

UploadGuard

CI NuGet NuGet NuGet License: MIT

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
PDF /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 StoragePath is inside the web root.
  • OpenReadAsync and DeleteAsync accept 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. Throw UploadRejectedException to reject; any other exception rejects with ScanFailed.
  • IUploadTransformer: add it with policy.Transform(...). It runs in SaveAsync after validation.
  • IUploadStorage: register it to replace the file system storage.

Observability

  • Metric uploadguard.uploads (meter UploadGuard), a counter tagged with uploadguard.policy, uploadguard.result (Accepted or the error code) and uploadguard.file_type. Add it with .AddMeter("UploadGuard") in OpenTelemetry.
  • Rejections are logged at Information with the policy and reason; scanner failures at Error.

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0-preview.2 54 11/7/2026
0.1.0-preview.1 54 11/7/2026