SecureFileUpload.Core
3.0.0
See the version list below for details.
dotnet add package SecureFileUpload.Core --version 3.0.0
NuGet\Install-Package SecureFileUpload.Core -Version 3.0.0
<PackageReference Include="SecureFileUpload.Core" Version="3.0.0" />
<PackageVersion Include="SecureFileUpload.Core" Version="3.0.0" />
<PackageReference Include="SecureFileUpload.Core" />
paket add SecureFileUpload.Core --version 3.0.0
#r "nuget: SecureFileUpload.Core, 3.0.0"
#:package SecureFileUpload.Core@3.0.0
#addin nuget:?package=SecureFileUpload.Core&version=3.0.0
#tool nuget:?package=SecureFileUpload.Core&version=3.0.0
SecureFileUpload.Core
Defense-in-depth file upload pipeline for ASP.NET Core 10+ — AES-256-GCM envelope encryption, Argon2id key derivation, deep content validation, and pluggable virus scanning.
SecureFileUpload.Core is a battle-tested file-upload pipeline lifted from a production ASP.NET Core document-intake workflow, de-branded, hardened, and shipped as a single NuGet package. Every layer is implemented in code you can read; every limitation is named in KNOWN-GAPS.md; every security claim traces to a specific line in src/ per the audit in SECURITY-ANALYSIS.md.
"So whether you eat or drink or whatever you do, do it all for the glory of God." — 1 Corinthians 10:31
What's New in 3.0.0
3.0.0 is the hardened download-surface release. The 8-layer upload pipeline and on-disk crypto formats are unchanged from 2.0.0. This release is about the staff-download contract, release validation, and operator-facing correctness.
- Opaque download tokens replace path-based download links. The reference controller now accepts
fileToken, issued byIFileAccessTokenService, instead of a storage-relative path. If you linked staff downloads withrelativePath, update that integration before upgrading. - Release validation is now an actual gate. The solution tests and the runtime smoke harness both run in CI before pack/publish, so the NuGet package is validated against the same path documented in this repo.
- Scanner outage logs now match runtime behavior. ClamAV and Windows Defender unavailability are logged as
NotScannedfail-open conditions instead of incorrectly implying fail-closed rejection.
2.0.0 was the first stable release of the modernized line. If you're upgrading from 1.0.0, the major crypto/runtime changes from that release still apply: the TFM moved from net8.0 to net10.0, and the KEK derivation default changed to Argon2id.
- Argon2id for KEK derivation. The master Key Encryption Key is now derived via Argon2id (RFC 9106, OWASP 2024+ recommendation) with memory-hard defaults —
m=64 MiB, t=3, p=4. Memory-hardness raises the cost-per-guess on GPUs and ASICs by orders of magnitude over the prior PBKDF2-SHA256 derivation. - Backward-compatible online upgrade. Files wrapped under prior PBKDF2 KEKs (600 000 and 210 000 iterations) still decrypt via
FileUpload:KeyDerivation:LegacyKekFallback=true(default). No file on disk is bricked by the upgrade. New writes always use the Argon2id-derived KEK. - Configurable KDF. Argon2id is the default;
KeyDerivation:Algorithm = "Pbkdf2"is available for FIPS-restricted environments. All Argon2id parameters and the PBKDF2 iteration count are tunable fromappsettings.json. - .NET 10. Target framework consolidated on
net10.0only. Pin1.0.x-preview.0if you need anet8.0-only build. - Packaging. Deterministic build, Source Link,
.snupkgsymbols, andREADME.md/LICENSE/SECURITY-ANALYSIS.md/KNOWN-GAPS.mdbundled inside the package itself.
The crypto posture, parameters, and honest residual risks are documented in Implementation & Crypto Posture below and in SECURITY-ANALYSIS.md.
Why this exists
File upload is one of the most consistently mishandled surfaces in web development. Most tutorials show you how to receive a file. Very few defend against:
- Polyglot files (a valid JPEG that is also a working PHP shell)
- Double-extension attacks (
photo.pdf.exe) - MIME spoofing and magic-byte forgery
- Path traversal via filename manipulation
- PDF JavaScript injection (including inside FlateDecode-compressed object streams)
- ZIP-bomb / pixel-flood attacks via image decoding
- Log poisoning via crafted filenames
- Disk exhaustion via batched uploads
- Direct web-serving of attacker-controlled bytes
This package addresses every item on that list in code, then names the gaps it does not close. The red-team review in SECURITY-ANALYSIS.md traces each claim to its source line.
The 8-layer pipeline
Every uploaded file passes through eight serial layers. Failure at any content-decision layer rejects the file. The pipeline is fail-closed on content; the single fail-open seam is virus-scanner availability (Layer 7), and that is documented, counted, and never silently relabelled as clean — see KNOWN-GAPS.md §Gap 9.
┌──────────────────────────────────────────────────────────────────────────┐
│ INCOMING FILE UPLOAD │
└──────────────────────────────┬───────────────────────────────────────────┘
│
┌───────────────────────▼────────────────────────┐
│ Layer 1 File size (per-file + batch total) │
│ Minimum size per format │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 2 Extension allowlist │
│ .jpg .jpeg .png .webp .pdf │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 3 MIME ↔ extension cross-validation │
│ Browser MIME must match extension │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 4 Magic-byte signature check │
│ JPEG / PNG / WebP fourCC / PDF │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 5 Filename inspection │
│ Double-extension, Unicode bidi, │
│ path traversal, Windows reserved │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 6 Deep content validation │
│ JPEG/PNG/WebP structural walkers, │
│ PDF byte-pattern scan, │
│ FlateDecode-compressed PDF stream │
│ decompression and re-scan │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 7 Virus scan (pluggable) │
│ Windows Defender OR ClamAV / clamd │
│ Detection fail-closed, │
│ Availability fail-open (tracked) │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 8 Encrypted storage │
│ AES-256-GCM envelope (v2): │
│ per-file random DEK │
│ wrapped under Argon2id-derived KEK │
│ Image recompression strips polyglot │
│ tails before encryption. │
│ Randomized filename, outside wwwroot. │
│ Path traversal re-checked before │
│ write via PathHelper.IsPathUnderBase. │
└──────────────────────────────────────────────────┘
Install
dotnet add package SecureFileUpload.Core
Requires .NET 10+ with ASP.NET Core. The package references Microsoft.AspNetCore.App as a framework reference, so nothing extra ships inside it — your runtime's existing ASP.NET Core does the heavy lifting.
If you need a net8.0 build, pin to 1.0.0 (the prior stable line) — Argon2id and net10.0 arrived in the 1.0.0-preview.2 candidates and are the default in 2.0.0.
Quick start
1. Register the services
// Program.cs
using SecureFileUpload.Services;
builder.Services.AddSecureFileUpload();
// Match this to FileUpload:MaxTotalUploadBytes in appsettings.json.
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 53_477_376; // 51 MB
});
AddSecureFileUpload() registers FileContentValidator, the platform-appropriate IVirusScanService (Windows Defender on Windows, ClamAV elsewhere), IFileUploadService, and IFileAccessTokenService in one call. The scanner backend is picked at startup; download tokens are issued through ASP.NET Core Data Protection with a short lifetime by default.
2. Receive an upload
[HttpPost]
[RequestSizeLimit(53_477_376)]
public async Task<IActionResult> Submit(MyInputModel model)
{
if (!ModelState.IsValid)
return View(model);
var result = await _fileUploadService.UploadFilesAsync(
Request.Form.Files, model.LastName, "intake");
if (!result.Success)
{
// result.Errors are user-safe; result.WorkflowOutcome is
// AllSaved | PartialSaved | AllRejected | NoFiles.
foreach (var msg in result.Errors)
ModelState.AddModelError(string.Empty, msg);
return View(model);
}
return RedirectToAction(nameof(Submitted), new { id = result.SubmissionFolder });
}
3. Serve a file safely
using SecureFileUpload.Services;
builder.Services
.AddAuthentication("Cookies")
.AddCookie("Cookies");
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("StaffFiles", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Staff");
// Add your own MFA / claim requirements here.
});
});
// SecureFileDownloadController is [Authorize] by default. Apply your stricter
// staff-only policy at the endpoint layer so the sample policy is actually used.
builder.Services.AddControllers().AddApplicationPart(typeof(SecureFileDownloadController).Assembly);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization("StaffFiles");
4. Issue an opaque download token
public sealed class StaffFilesController : Controller
{
private readonly IFileAccessTokenService _fileAccessTokenService;
public StaffFilesController(IFileAccessTokenService fileAccessTokenService)
{
_fileAccessTokenService = fileAccessTokenService;
}
public IActionResult DownloadFirst(FileUploadResult result)
{
string token = _fileAccessTokenService.CreateToken(result.UploadedFilePaths[0]);
string url = $"/staff/files/download?fileToken={Uri.EscapeDataString(token)}";
return Redirect(url);
}
}
The token is opaque, signed, and short-lived by default. Staff-facing URLs never
need to expose a storage-relative path.
If your application has both public and staff-only controllers, scope the
RequireAuthorization("StaffFiles") call to the staff route set instead of every
controller endpoint in the app.
A complete appsettings.json reference is in Configuration below.
Implementation & Crypto Posture
This section names primitives, parameters, and residual risks. It is the single source of truth for the cryptographic posture; the marketing tagline is at the top of this README.
| Aspect | Implementation | Notes |
|---|---|---|
| Symmetric encryption | AES-256-GCM via System.Security.Cryptography.AesGcm |
96-bit nonce, 128-bit auth tag — NIST SP 800-38D / RFC 5288. |
| Encryption scheme | Envelope (v2) — per-file random 256-bit DEK wrapped under a master KEK | KEK rotation rewraps DEKs without re-encrypting file payloads. |
| KEK derivation (writes) | Argon2id via Konscious.Security.Cryptography.Argon2 1.3.x |
RFC 9106; OWASP 2024+ recommendation. Memory-hard. |
| Argon2id parameters (defaults) | m=64 MiB, t=3, p=4, fixed application salt |
Above OWASP server-side minimum; targets ~250–500 ms derivation on a modern x64 core. |
| KEK derivation (decrypt fallback) | PBKDF2-SHA256, 600 000 and 210 000 iterations | Tried during decryption when LegacyKekFallback=true; never for new writes. |
| RNG | RandomNumberGenerator (CSPRNG) for DEKs, nonces, filename suffixes |
No System.Random, no Guid.NewGuid(), no DateTime.Ticks in security paths. |
| Storage format markers | ENCGCM\0\x01 (legacy single-key) / ENCGCM\0\x02 (current envelope) |
Layout: marker‖dek_nonce‖dek_tag‖wrapped_dek‖file_nonce‖file_tag‖ciphertext. |
| Buffer hygiene | Plaintext, DEK, and password buffers zeroed via CryptographicOperations.ZeroMemory |
Reduces in-memory exposure window. Not a guarantee against GC copies. |
| Startup guards | EncryptionEnabled=true + missing/placeholder secret ⇒ InvalidOperationException |
Misconfigurations fail loudly at deploy time, not silently at runtime. |
| FIPS posture | Not FIPS-validated. Argon2id is not in FIPS 140-3 ASMs as of 2026 | Opt into KeyDerivation:Algorithm = "Pbkdf2" for FIPS-only deployments. |
| TLS / transport | Not provided by this library | Enforce HSTS and HTTPS at Kestrel / reverse-proxy level. |
| At-rest device encryption | Not provided by this library | BitLocker / LUKS / dm-crypt strongly recommended on the storage volume. |
Honest limitations
- The KDF salt is in source. It is identical across deployments of this library version. The protection model assumes the secret lives in a real secrets manager (Key Vault, AWS Secrets Manager, env var injected by the platform) — never
appsettings.jsoncommitted to a repo. - Argon2id is not FIPS-validated. Compliance-bound deployments must select
Pbkdf2explicitly. - The KEK lives in process memory. A memory-disclosure or core-dump capability on the host bypasses the KDF entirely. Mitigations are deployment-level (least privilege, ASLR, sealed VMs, confidential compute).
- No HSM / KMS integration in v1. A KMS-backed KEK is tracked in
docs/hardening-roadmap.mdas the right next step for high-assurance deployments. - Argon2id parameters are CPU/RAM-bound. On a constrained container, startup derivation may take longer than the ~250–500 ms target. The library logs
KDF_ARGON2ID_DERIVED | ElapsedMs=...so this is measurable.
For the full code-traced security review, see SECURITY-ANALYSIS.md. For things this pipeline does not protect against, see KNOWN-GAPS.md.
Configuration
{
"FileUpload": {
"StorageRoot": "../uploads",
"MaxFileSizeBytes": 10485760,
"MaxFileCount": 5,
"MaxTotalUploadBytes": 52428800,
"MinStorageFreeBytes": 536870912,
"MinTempFreeBytes": 536870912,
"LowDiskWarningBytes": 2147483648,
"RecompressImages": true,
"JpegRecompressQuality": 95,
"EncryptionEnabled": false,
"EncryptionSecret": "CHANGE_THIS_TO_A_REAL_SECRET_MINIMUM_32_CHARS",
"KeyDerivation": {
"Algorithm": "Argon2id",
"Argon2id": {
"MemoryKiB": 65536,
"Iterations": 3,
"Parallelism": 4
},
"Pbkdf2": {
"Iterations": 600000
},
"LegacyKekFallback": true
}
},
"FileContent": {
"InspectCompressedPdfStreams": true,
"MaxCompressedStreamsToInspect": 64,
"MaxDecompressedStreamBytes": 16777216,
"RejectEncryptedPdfs": true,
"RejectInteractivePdfs": false,
"MaxImageWidth": 10000,
"MaxImageHeight": 10000,
"MaxImagePixels": 40000000
},
"FileDownload": {
"TokenLifetimeMinutes": 15
},
"VirusScan": {
"Enabled": false,
"WindowsDefender": {
"MpCmdRunPath": "C:\\Program Files\\Windows Defender\\MpCmdRun.exe",
"TempScanPath": "C:\\Temp\\VirusScan",
"TimeoutSeconds": 30
},
"ClamAv": {
"Host": "localhost",
"Port": 3310,
"TimeoutSeconds": 30,
"MaxStreamBytes": 26214400
}
}
}
| Setting | Purpose |
|---|---|
FileUpload:StorageRoot |
Resolved relative to ContentRootPath. Must land outside wwwroot. The service refuses to start otherwise. |
FileUpload:EncryptionSecret |
≥ 32 chars, must not contain CHANGE_THIS. Store in a secrets manager — never in checked-in config. |
FileUpload:KeyDerivation:Algorithm |
Argon2id (default) or Pbkdf2 (FIPS-restricted environments only). |
FileUpload:KeyDerivation:Argon2id:* |
Tune for your CPU/RAM budget. Library logs derivation time at startup. |
FileUpload:KeyDerivation:LegacyKekFallback |
true (default) keeps PBKDF2 fallback KEKs available for decryption only. Set false after every file has been re-wrapped. |
FileUpload:RecompressImages |
true (default) strips polyglot tails by re-encoding JPEG/PNG/WebP through ImageSharp. |
FileDownload:TokenLifetimeMinutes |
Lifetime for opaque download tokens issued by IFileAccessTokenService. Default 15 minutes; max 24 hours. |
VirusScan:Enabled |
When false, Layer 7 is bypassed. Layers 1–6 + 8 still run. |
VirusScan:ClamAv:MaxStreamBytes |
Must align with StreamMaxLength in your clamd.conf. |
Dependencies
Declared by the NuGet package — no manual installation required:
- ASP.NET Core 10+ shared framework (via
FrameworkReference) - SixLabors.ImageSharp 3.1.x — image identification and polyglot-tail recompression
- Konscious.Security.Cryptography.Argon2 1.3.x — Argon2id KEK derivation
Required at runtime if VirusScan:Enabled=true (not NuGet packages):
- Windows Defender (
MpCmdRun.exe) — Windows only, used byWindowsDefenderScanService - ClamAV (
clamdlistening on TCP) — Linux / macOS / containers, used byClamAvScanService
The scanner is selected automatically by AddSecureFileUpload() based on OperatingSystem.IsWindows().
Source layout
| File | Responsibility |
|---|---|
src/FileUploadService.cs |
Orchestrates the 8-layer pipeline. Batch limits, capacity checks, image recompression (Gap 1), envelope encryption (v2), Argon2id KEK derivation with PBKDF2 fallback, log-poisoning-safe filename handling. |
src/FileContentValidator.cs |
Layer 6 deep validation. JPEG / PNG / WebP structural walking, PDF pattern scan, FlateDecode-compressed PDF stream inspection (Gap 2). Fail-closed on unknown types. |
src/WindowsDefenderScanService.cs |
Layer 7 — Windows Defender MpCmdRun.exe. Secure-delete (zero-before-delete) of temp files. |
src/ClamAvScanService.cs |
Layer 7 — clamd over TCP using zINSTREAM. No temp file is written. Cross-platform. |
src/FileAccessTokenService.cs |
Opaque, signed, time-limited download tokens backed by ASP.NET Core Data Protection. |
src/SecureFileDownloadController.cs |
Reference hardened download surface. Tokenized file reference, Content-Disposition: attachment, strict CSP, COOP/COEP/CORP, re-checks resolved path traversal. |
src/DependencyInjection/SecureFileUploadServiceCollectionExtensions.cs |
AddSecureFileUpload() one-liner DI registration. |
src/Utilities/PathHelper.cs |
Canonicalized IsPathUnderBase — defeats the string.StartsWith("/uploads") prefix-confusion bug. |
tests/Fuzz/ |
SharpFuzz + AFL++ harness for FileContentValidator.ValidateAsync. |
tests/SmokeTest/ |
Runtime smoke test — Argon2id round-trip, v2 envelope, legacy PBKDF2 fallback, misconfig guard. Executed in CI and runnable locally with dotnet run --project tests/SmokeTest -c Release. |
Docs
SECURITY-ANALYSIS.md— full code-traced security review, with each claim pointing at source linesKNOWN-GAPS.md— honest limitations and what this does NOT protect againstdocs/threat-model.md— what attack each layer defeatsdocs/hardening-roadmap.md— recommended next steps toward the strongest realistic posturetests/attack-vectors.md— per-layer attack test casestests/Fuzz/— SharpFuzz + AFL++ harness for the deep content validator
Release process
Publishing is handled by GitHub Actions in .github/workflows/nuget-publish.yml.
- Push to
mainruns library build, solution tests, the runtime smoke harness, pack, and a fuzz-harness build — no publish. - Push a
v*tag to publish to NuGet.org. The workflow derives the package version from the tag (v2.0.0→2.0.0). --skip-duplicateis used, so re-running on an existing version is non-destructive.
git tag v2.0.0
git push origin v2.0.0
The <Version> in src/SecureFileUpload.Core.csproj is the source of truth for local packs and CI artifacts; tag builds override it for the published NuGet version.
Contributing
Issues and PRs welcome — especially:
- Unit-test coverage for the per-layer validation paths
- Additional format validators (GIF, BMP deep content validation)
- Async / queued virus-scan worker for higher-volume deployments
- KMS / HSM-backed KEK provider
If you're filing a security report, please open a private advisory on GitHub rather than a public issue.
License
MIT. Use freely. Attribution appreciated, not required.
"So whether you eat or drink or whatever you do, do it all for the glory of God." — 1 Corinthians 10:31
| 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
- Konscious.Security.Cryptography.Argon2 (>= 1.3.1)
- SixLabors.ImageSharp (>= 3.1.11)
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 |
|---|---|---|
| 3.0.3 | 69 | 6/2/2026 |
| 3.0.2 | 97 | 5/30/2026 |
| 3.0.1 | 90 | 5/30/2026 |
| 3.0.0 | 91 | 5/30/2026 |
| 2.0.0 | 83 | 5/30/2026 |
| 1.0.0 | 112 | 4/21/2026 |
| 1.0.0-preview.3 | 46 | 5/30/2026 |
| 1.0.0-preview.2 | 37 | 5/30/2026 |
v3.0.0 — hardened download-surface release.
BREAKING CHANGES vs. 2.0.0:
• The reference download endpoint now accepts an opaque `fileToken` instead
of a storage-relative `relativePath` query parameter. Consumers must issue
tokens via `IFileAccessTokenService.CreateToken(...)` and pass that token to
`/staff/files/download?fileToken=...`.
• `AddSecureFileUpload()` now registers `IFileAccessTokenService` and the
reference controller/docs assume tokenized download links rather than
path-based file references.
Highlights:
• Opaque, signed, time-limited download tokens backed by ASP.NET Core Data
Protection. Staff-facing URLs no longer expose storage-relative paths.
• Release validation now includes solution tests plus the runtime smoke harness
before pack/publish.
• Regression coverage expanded for download authorization, token tampering,
infected-file rejection, scanner fail-open semantics, and storage-root token
enforcement.
• Scanner outage logging now matches the actual `NotScanned` fail-open pipeline
behavior, avoiding misleading fail-closed wording in production logs.
The 8-layer upload pipeline, on-disk envelope formats, and Argon2id / PBKDF2
decryption compatibility are unchanged from 2.0.0.