SecureFileUpload.Core
3.2.1
dotnet add package SecureFileUpload.Core --version 3.2.1
NuGet\Install-Package SecureFileUpload.Core -Version 3.2.1
<PackageReference Include="SecureFileUpload.Core" Version="3.2.1" />
<PackageVersion Include="SecureFileUpload.Core" Version="3.2.1" />
<PackageReference Include="SecureFileUpload.Core" />
paket add SecureFileUpload.Core --version 3.2.1
#r "nuget: SecureFileUpload.Core, 3.2.1"
#:package SecureFileUpload.Core@3.2.1
#addin nuget:?package=SecureFileUpload.Core&version=3.2.1
#tool nuget:?package=SecureFileUpload.Core&version=3.2.1
SecureFileUpload.Core
The defense-in-depth file-upload pipeline from a real production library system, packaged for ASP.NET Core 8 / 9 / 10.
SecureFileUpload.Core gives ASP.NET Core a production-ready, defense-in-depth file-upload pipeline. It validates, scans, encrypts, and safely stores uploaded files behind eight serial security layers — attacker-controlled bytes never land in wwwroot, never get served back verbatim, and never sit unencrypted on disk. One AddSecureFileUpload() call wires the whole thing up.
The eight layers, in pipeline order:
- File size + batch limits — per-file and per-batch byte caps enforced before buffering
- Extension allowlist —
.jpg.jpeg.png.webp.pdf; everything else stops here - MIME ↔ extension cross-validation — browser-reported
Content-Typemust match the extension - Magic-number (file signature) validation — header bytes must match the claimed format
- Filename / path sanitization — NFKC-normalized; blocks traversal, double extensions, NTFS ADS, Unicode bidi, Windows reserved names
- Deep structural content validation — JPEG/PNG/WebP/GIF/BMP walkers plus a token-aware PDF scanner that inflates FlateDecode streams, under hard caps for ratio, time, and recursion depth
- Malware scanning — Windows Defender or ClamAV, pluggable; detection always fail-closed
- AES-256-GCM encrypted storage — per-file random DEK wrapped under an Argon2id-derived master KEK, randomized filename, outside the web root
Reads go back out through a hardened reference download surface that takes opaque, signed, time-limited tokens instead of storage paths — optionally bound to the issuing user.
The pipeline is lifted from the document-intake workflow of a live public-library patron-registration system, de-branded and hardened for general use. 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.
This package is independent of the PostQuantum.* family. It is a classical (non-PQC) security library: AES-256-GCM at rest, Argon2id for the master KEK, no post-quantum asymmetric layer. See
SECURITY.md → Crypto classificationfor the explicit posture statement.
"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.2.1
3.2.1 is a documentation patch. No code, IL, or on-disk format change; AssemblyVersion stays at 3.2.0.0, so it is a drop-in upgrade from 3.2.0.
- The PDF object-stream config key was documented wrong in
3.2.0. The correct key isFileContent:RejectPdfObjectStreams, notFileUpload:ContentValidation:RejectPdfObjectStreams—AddSecureFileUpload()bindsFileContentValidatorOptionsto the top-level"FileContent"section, so every property on that class lives under that prefix. If you set the previously-documented key, it was silently ignored and the default (false) applied. That default is the recommended value, so nothing was weakened — but an operator deliberately opting in to rejecting object streams was not getting it. - Documentation links on the NuGet gallery now work. The packaged README used relative markdown paths, which the gallery does not resolve against the source repository — every doc link on the package page was dead. All of them are now absolute GitHub URLs.
- The packaged README was two releases stale (its newest section was
3.0.3) and carried the pre-3.2.0image dimension caps in the sampleappsettingsblock. Release notes, config sample, and settings table are current; seeCHANGELOG.mdfor the full list.
What's New in 3.2.0
3.2.0 makes the deep content validator token-aware and fixes the false rejections that a substring-matching PDF scanner produces on real-world files. No public API break: FileContentValidatorOptions gains one property, and three defaults change — all revertible by config.
This release back-ports the validator rework from the production intake application this package was lifted from and merges it with the compressed-stream scanner that only existed here. Neither side had both.
- PDF tokens are matched by extracting real name objects and decoding
#xxhex escapes./J#53no longer slips past the/JScheck, and/JSONno longer trips it. The same lexer now drives the FlateDecode scanner, so/JavaScriptor/Launchdeclared inside a compressed object stream is found by name extraction rather than raw substring — hex-escaped spellings inside object streams are inspected for the first time. StripPdfStreamPayloadstreatsstreamas a lexical token. A plainIndexOfmatched inside comments, literal strings, and names such as/upstream, which discarded the rest of the document and hid every dangerous token that followed. Similarly,FindPdfTokenno longer treats(and%as string/comment markers while scanning raw stream bytes — that mis-parse swallowed the realendstreamand structurally rejected any PDF with an indirect/Length(Ghostscript, MFP firmware, PDF/A converters).FileContent:RejectPdfObjectStreamsdefaults tofalse. Object streams are standard in PDF 1.5+ — Word, Acrobat, browser "Print to PDF", and every mainstream phone scanner app emit them — so refusing them outright refused ordinary documents. Their contents are now inflated and name-scanned instead, a net increase in coverage. Operators with a controlled producer set can flip it totrue.- Post-EOI JPEG data is scanned but no longer rejected for failing to match a recognized container. The old allowlist refused MPF second images (Samsung, Sony, Fujifilm), Samsung Motion Photo SEF footers, and alignment padding — the most common trailers in the wild. Detection now comes from scanning those bytes, and JPEG end-of-image is located by walking segments and the entropy-coded scan, which is what makes post-EOI data findable at all.
- Image dimension caps raised to 30 000 px / 300 MP (from 10 000 px / 40 MP). These guard against decompression pixel bombs only; the previous values false-rejected ordinary 48–200 MP smartphone photos.
- Image text-threat scans are confined to metadata and comment segments (JPEG
APPn/COM, PNGtEXt/iTXt/zTXt, WebPEXIF/XMP, GIF comment/application) instead of running across entropy-coded pixel data, so coincidental byte patterns in photo data no longer false-reject. Regions are delimited so a pattern cannot be synthesized across two unrelated segments. Compressed PNG text chunks (zTXt, flaggediTXt) are inflated before scanning, under per-chunk and total output bounds. Ambiguous PHP short-open-tag patterns are gone, ending falseJPEG-EmbeddedShellrejections of legitimate phone photos. - Atomic upload writes. Files are written to a temporary sibling and moved into place, so an interrupted write never leaves a partial file at the destination; success is logged only after the move.
GetDecryptedFileStreamAsyncnow normalizes the path, enforces containment under the storage root, and rejects reparse points in every path component. VirusScanOutcome.NotScannedsplits intoDisabledandUnavailableso "no scanner configured" and "configured scanner failed" are distinguishable in logs. Both remain fail-open and both count towardScanNotScannedCount. Scan counters increment only after storage succeeds, so a file that is scanned and then fails to write is no longer reported as stored.SixLabors.ImageSharppinned to[3.1.11,4.0.0). ImageSharp moved to a commercial Six Labors license at v4 — the build fails without a paid license key, and shipping it would push that obligation onto every consumer. The range still admits 3.1.x security patches, which matters because ImageSharp is fed attacker-controlled bytes by design.- Threat scans iterate every occurrence of a pattern instead of stopping at the first (a short decoy failing the printable-run gate used to abandon the pattern entirely), and cancellation is checked before deep validation for every file type, not only PDFs carrying a compressed stream.
- No change to the 8-layer pipeline order, the on-disk envelope formats, the Argon2id KEK derivation, or the legacy PBKDF2 decrypt fallback.
What's New in 3.1.0
3.1.0 closes the sanitization fallback described in KNOWN-GAPS.md Gap 1 and adds cryptographic user-binding for download tokens. No public API break — FileAccessTokenService gains one optional, DI-resolved constructor parameter, so existing call sites compile unchanged. One deliberate behavioral change, revertible by config.
- Image recompression now fails closed. Previously, if the sanitizing re-encode failed, the pipeline logged a warning and stored the original validated bytes. But a file whose header parses while its pixel data fails a full decode is exactly the shape of a crafted polyglot, so that fallback silently defeated the mitigation and kept any appended tail on disk. The upload is now rejected with
SECURITY_EVENT | FILE_SAVE_BLOCKED_SANITIZATION. SetFileUpload:RejectOnRecompressFailure=falseto restore the old behavior. - Download tokens can be cryptographically bound to the issuing user.
FileDownload:BindTokensToUser(defaultfalse). When enabled, the authenticated identity (ClaimTypes.NameIdentifier, falling back toIdentity.Name) is folded into the Data Protection purpose chain at creation — a token replayed by a different account fails cryptographic verification, not merely a policy check. Issuing a token on an unauthenticated request throws, and startup fails fast if the flag is on with noIHttpContextAccessor.AddSecureFileUpload()now callsAddHttpContextAccessor(). - User-facing error strings no longer echo HTML-active characters.
SanitizeForLogneutralizes<,>,",'→?. Its output is embedded inFileUploadResult.Errors; a consumer rendering those without encoding could previously be handed markup from a filename like<svg onload=…>.jpg. - Two scanner misconfigurations that silently disabled scanning are fixed. The Windows Defender timeout now clamps with a 1-second lower bound (matching ClamAV) — a configured
0previously made every scan time out instantly, silently disabling scanning under the fail-open availability default. ClamAVMaxStreamBytes≤ 0 now falls back to the 25 MiB default with a warning instead of failing every scan. - Supply chain. GitHub Actions pinned to commit SHAs in both workflows (tag pinning is mutable); Dependabot enabled for the
nugetandgithub-actionsecosystems so ImageSharp advisories open PRs automatically. HardeningV310Testscover the fail-closed and fallback recompression paths, a valid-image round-trip (no over-rejection), HTML-neutralized error output, and the full token-binding matrix: same-user resolve, cross-user replay rejection, anonymous issuance refusal, unbound default round-trip.- No change to the 8-layer pipeline order, on-disk envelope formats, Argon2id KEK derivation, PBKDF2 legacy decrypt fallback, or the plaintext/DEK zeroing discipline.
What's New in 3.0.3
3.0.3 is a defense-in-depth hardening patch. No public API break.
- Filename validation is NFKC-normalized. A fullwidth
..(U+FF0E ×2) can no longer pretend not to be..; fullwidth reserved names (CON.pdf) and fullwidth-disguised double-extensions (evil.exe.pdf) are caught alongside their literal forms. Trailing dot and trailing space are rejected before Windows path resolution strips them. Hard 255-character length cap. Legitimate non-ASCII filenames (accented Latin, CJK, Cyrillic, Greek, etc.) pass through unchanged — NFKC is identity on those. - PDF deep validation gains hard caps against decompression bombs and polyglots. Per-stream decompression-ratio cap (default
200×), per-file wall-clock timeout (default2 000 ms), bounded nested-stream recursion depth (default2for/ObjStm), and fullCancellationTokenpropagation through the FlateDecode walker. - AV availability mode is now configurable.
VirusScan:FailClosedOnUnavailable(defaultfalse= prior fail-open behavior). Settrueto reject the upload when the scanner cannot give a verdict. Detection mode is always fail-closed regardless of this flag. A uniformVIRUS_SCAN_SKIPPEDsecurity event fires in both modes — operators alert on a single signal. - Crypto classification is now explicit. At-rest encryption is classical AES-256-GCM (quantum-tolerant by key size for confidentiality, but no PQ asymmetric layer). New
SECURITY.mdstates the posture and the deliberate separation from thePostQuantum.*family. HardeningRegressionTestsadds 25 cases covering filename evasions and legitimate-Unicode acceptance, decompression-bomb rejection inside the time budget, nested FlateDecode recursion, cancellation propagation, fail-closed AV mode, concurrent encrypted uploads, andPathHelper.IsPathUnderBaseencoded-separator resistance. Fuzz harness gains a triage assertion that treats anyAllowedverdict on a curated seed undertests/Fuzz/seeds/as a finding.- No change to the 8-layer pipeline order, the v2 envelope encryption format (
ENCGCM\0\x02), the Argon2id KEK derivation, the PBKDF2 legacy decrypt fallback, or the plaintext / DEK / KDF-input zeroing discipline. Smoke harness still 18/18 green.
Earlier releases
Full detail for every release is in CHANGELOG.md.
3.0.2— restored multi-targeting fornet8.0/net9.0/net10.0. Narrowing tonet10.0-only in3.0.0forced consumers on supported LTS/STS runtimes to pin to2.xor upgrade their host before taking any3.xhardening. Same source, no conditional compilation — the pipeline already compiled against the .NET 8 BCL.3.0.1— documentation and code-hygiene patch: multi-instance Data Protection key-persistence guidance, explicit token-replay window documentation.3.0.0— hardened download surface. Breaking: the reference endpoint takes an opaquefileTokenfromIFileAccessTokenServiceinstead of a storage-relativerelativePath; update that integration before upgrading. Release validation became an actual CI gate (solution tests plus runtime smoke harness before pack/publish), and scanner-outage logs were corrected to reflect the real fail-openNotScannedbehavior.2.0.0— first stable release of the modernized line, and the origin of the current crypto posture. Argon2id (RFC 9106, OWASP 2024+) replaced PBKDF2-SHA256 for master-KEK derivation with memory-hard defaultsm=64 MiB, t=3, p=4. Files wrapped under prior PBKDF2 KEKs (600 000 and 210 000 iterations) still decrypt viaFileUpload:KeyDerivation:LegacyKekFallback=true(default), so no file on disk was bricked; new writes always use the Argon2id KEK.KeyDerivation:Algorithm = "Pbkdf2"remains available for FIPS-restricted environments. Deterministic build, Source Link, and.snupkgsymbols landed here.
The crypto posture, parameters, and honest residual risks are documented in Implementation & Crypto Posture below and in SECURITY-ANALYSIS.md.
What this is
Most ASP.NET Core upload examples show you how to receive a file. This package is the production version of an intake workflow that has accepted thousands of patron documents — driver's licenses, utility bills, library replacement-card paperwork — under real-world adversarial conditions. The eight layers exist because each one caught something in production:
- Polyglot files (a valid JPEG that is also a working PHP shell)
- Double-extension attacks (
photo.pdf.exe, including fullwidth-Unicode disguises after NFKC normalization) - MIME spoofing and magic-byte forgery
- Path traversal via filename manipulation, NTFS alternate data streams, and Windows reserved device names
- PDF JavaScript injection (including inside FlateDecode-compressed object streams, with hard caps on stream count, decompressed bytes, decompression ratio, and wall-clock time)
- 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. You do not need to care about cryptography to adopt the upload pipeline — crypto details are in §Implementation & Crypto Posture below; the validation pipeline runs identically whether or not you read that section.
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. │
└──────────────────────────────────────────────────┘
What each layer actually does
For an ASP.NET developer adopting this pipeline without any cryptography interest, here is the one-paragraph "what does this defend against" view of each layer. Crypto details remain in §Implementation & Crypto Posture below — the pipeline runs identically whether or not you read that section.
| Layer | Defends against | One-line summary |
|---|---|---|
| 1 — Size + batch | Disk-exhaustion DoS, oversized scans | Per-file and per-batch byte caps enforced before any buffering. |
| 2 — Extension allowlist | Wrong-format uploads, executables-by-name | Only .jpg .jpeg .png .webp .pdf proceed. Everything else stops here. |
| 3 — MIME ↔ extension cross-check | MIME-spoofing toolchains | Browser-reported Content-Type must match the extension. Mismatch rejects. |
| 4 — Magic-byte signature | Renamed .exe → .pdf and disguised payloads |
Header bytes must match the format; known-dangerous headers (PE/ELF/Mach-O/OLE/PHP/script) are named in the log message so an operator can spot the disguise. |
| 5 — Filename inspection | Path traversal, NTFS alternate data streams, Unicode bidi/zero-width tricks, double-extension (photo.pdf.exe), Windows reserved device names (CON, PRN, NUL, COM1-9, LPT1-9), trailing dots/spaces, control characters, fullwidth-Unicode disguises |
NFKC-normalized scan so a fullwidth .. cannot pretend not to be ..; 255-character length cap; trailing . or space is rejected before Windows path resolution strips it. |
| 6 — Deep content validation | Polyglot files, PDF JavaScript / /Launch / /EmbeddedFile / JBIG2, embedded executables, ZIP-bomb image dimensions, PHP shells in image metadata |
Format-specific structural walkers (JPEG segments, PNG chunks, WebP RIFF tree, GIF blocks, BMP DIB) plus a FlateDecode-compressed PDF stream scanner with hard caps on stream count, total decompressed bytes, per-stream decompression ratio, per-file wall-clock time, and recursion depth for nested compressed object streams. Fail-closed on any unknown type or exception. |
| 7 — Virus scan | Known-bad signatures the prior layers can't fingerprint | Windows Defender (MpCmdRun.exe) on Windows, ClamAV (clamd over TCP) elsewhere. Detection is always fail-closed; availability is a configured operator choice — see §AV failure mode below. |
| 8 — Encrypted storage | At-rest disclosure if the storage volume leaks or is exfiltrated | AES-256-GCM envelope (v2): per-file random 256-bit DEK wrapped under an Argon2id-derived master KEK. Image recompression strips polyglot tails before encryption. Randomized filename outside wwwroot. Final PathHelper.IsPathUnderBase re-check at write time. Plaintext, DEK, and KDF-input buffers zeroed in finally blocks. |
AV failure mode: fail-open vs. fail-closed (explicit operator choice)
When the virus scanner is unreachable (clamd down, MpCmdRun.exe missing, timeout, parser error, exception), the pipeline must pick one of two behaviors. As of 3.0.3 this is an explicit configuration option:
- Fail-open on availability —
VirusScan:FailClosedOnUnavailable=false(default; matches prior behavior). The file is accepted and recorded asUnavailable(orDisabledwhen no scanner is configured — the two were a singleNotScannedoutcome before3.2.0), counted inFileUploadResult.ScanNotScannedCount, and a singleVIRUS_SCAN_SKIPPEDsecurity event is emitted withReason=ScannerUnavailableso operators can alert on a non-zero count per window. This is the trade-off the original library deployment made: a Defender hiccup must not block patrons from registering for a library card. - Fail-closed on availability —
VirusScan:FailClosedOnUnavailable=true. Scanner unavailability rejects the upload with a clearscanner unavailableworkflow error. The sameVIRUS_SCAN_SKIPPEDmetric is still emitted so operations can alert identically in either mode.
Detection (an Infected verdict from a reachable scanner) is always fail-closed, regardless of this setting. The knob only controls what happens when the scanner cannot give a verdict at all. Pick the mode that matches your environment's risk tolerance; do not pick by default.
Install
dotnet add package SecureFileUpload.Core
Multi-targets net8.0, net9.0, and net10.0 — same source, same crypto posture, on every currently-supported .NET runtime. 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.
Pick the TFM that matches your host:
| Host runtime | NuGet picks | Notes |
|---|---|---|
| .NET 8 (LTS, in support) | net8.0 build |
Same FileUploadService / FileContentValidator source. |
| .NET 9 (STS, in support) | net9.0 build |
Same source. |
| .NET 10 (LTS, current) | net10.0 build |
Same source. |
Upgrading from any
3.x? No public API break anywhere in the line, and the on-disk envelope formats (ENCGCM\0\x01/ENCGCM\0\x02) are byte-for-byte compatible throughout — no re-wrap, no migration.AssemblyVersiontracks the package version from3.1.0onward (it was held at3.0.0.0across the3.0.xpatches), so coming from3.0.xon a framework that uses binding redirects, refresh them. Two behavioral changes are worth knowing about, both revertible by config: image recompression fails closed as of3.1.0(FileUpload:RejectOnRecompressFailure, defaulttrue), and3.2.0accepts PDF object streams and larger image dimensions rather than rejecting them (RejectPdfObjectStreams,MaxImageWidth/Height/Pixels). If you are coming from2.x, the3.0.0download-endpoint change is breaking — see Earlier releases above.
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.
Deployment notes
Data Protection and multi-instance deployments
AddSecureFileUpload() calls services.AddDataProtection() so that IFileAccessTokenService can sign download tokens. With the default registration, each process generates its own ephemeral key ring — fine for a single-instance app, but broken across replicas: a token issued by node A will not validate on node B, so any load-balanced staff request to /staff/files/download has a chance of returning 400 Invalid file reference.
For any deployment with more than one instance (Kubernetes, multiple App Service workers, an autoscaled VM scale set, dev → staging container, blue-green), configure Data Protection to share a key store and pin an application name before AddSecureFileUpload():
// Pick ONE persistence backend that all instances can read.
// Shared filesystem mount (Linux + ReadWriteMany PVC, Windows file share):
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/keys/secure-file-upload"))
.SetApplicationName("SecureFileUpload");
// — OR — Azure Blob + Key Vault (recommended on Azure):
// builder.Services.AddDataProtection()
// .PersistKeysToAzureBlobStorage(blobUri, credential)
// .ProtectKeysWithAzureKeyVault(keyIdentifier, credential)
// .SetApplicationName("SecureFileUpload");
builder.Services.AddSecureFileUpload();
SetApplicationName(...) is the gate that makes keys interchangeable across processes — without it, ASP.NET Core derives a per-content-root application discriminator and tokens still won't cross instances even with a shared key store. The string itself is not a secret; just keep it stable across deployments.
Symptom of getting this wrong: intermittent DOWNLOAD_REJECTED_BAD_TOKEN warnings in the logs, only on multi-instance environments, only for tokens issued by a different instance than the one handling the download. The single-instance happy-path keeps working, which makes it easy to ship the misconfiguration. Catch it in load-balanced staging.
Token replay window
A signed download token is reusable for its configured lifetime (FileDownload:TokenLifetimeMinutes, default 15 minutes). If a token leaks via a referrer, a screen recording, a log entry, or shared-screen support, an attacker with network access to the staff endpoint can replay it until expiry. Mitigations layered into the library and the recommended deployment:
Cache-Control: no-store, no-cache, must-revalidate, privateon every download response — no shared proxy keeps a copy.Referrer-Policy: no-referrerso the token doesn't leak to other origins.[Authorize]onSecureFileDownloadControllerplus the recommendedRequireAuthorization("StaffFiles")policy — a leaked token is useless to an unauthenticated attacker.- Short default lifetime; lower it further for high-sensitivity workflows.
- User binding (3.1.0, opt-in): set
FileDownload:BindTokensToUser=trueand the authenticated user's identity (ClaimTypes.NameIdentifier, falling back toIdentity.Name) is folded into the Data Protection purpose chain at token creation. The token then only unprotects for a request carrying the same identity — a token replayed by any other account fails cryptographic verification, not just a policy check. Issuing a token on an unauthenticated request throws. RequiresIHttpContextAccessor, whichAddSecureFileUpload()registers for you. Tokens issued before enabling the flag stop validating (they are unbound) — a non-issue in practice given the 15-minute default lifetime.
With user binding enabled, the replay window shrinks to "the same authenticated account within the token lifetime." There is still no single-use / nonce-redemption mode in v3. If you need one, track it as a v3.x feature request — the right shape is a redemption store keyed by token hash, gated by IFileAccessTokenService.
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 |
|---|---|---|
| Crypto classification | Classical, not post-quantum. AES-256-GCM provides quantum-resistant confidentiality by key size (Grover's algorithm halves the effective key size to 128 bits, which remains comfortable). There is no PQ asymmetric layer — no ML-KEM, no ML-DSA, no asymmetric primitives at all. | This package is intentionally separate from the PostQuantum.* family. No PQC migration path is planned for v3.x. See SECURITY.md for the explicit posture. |
| 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,
"RejectOnRecompressFailure": true,
"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,
"MaxDecompressionRatio": 200,
"MaxPdfStreamScanMilliseconds": 2000,
"RejectEncryptedPdfs": true,
"RejectInteractivePdfs": false,
"RejectPdfObjectStreams": false,
"MaxImageWidth": 30000,
"MaxImageHeight": 30000,
"MaxImagePixels": 300000000
},
"FileDownload": {
"TokenLifetimeMinutes": 15,
"BindTokensToUser": false
},
"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. |
FileUpload:RejectOnRecompressFailure |
true (default, 3.1.0) rejects the upload when the sanitizing re-encode fails — a header that parses but a decode that fails is the shape of a crafted polyglot. Set false to restore the pre-3.1.0 store-original-bytes fallback. |
FileDownload:TokenLifetimeMinutes |
Lifetime for opaque download tokens issued by IFileAccessTokenService. Default 15 minutes; max 24 hours. |
FileDownload:BindTokensToUser |
false (default). When true, download tokens are cryptographically bound to the issuing authenticated user; replay from any other account fails verification. |
FileContent:RejectPdfObjectStreams |
false (default, 3.2.0). Object streams are standard in PDF 1.5+, so they are accepted and their inflated contents name-scanned. Set true only with a controlled PDF producer set — expect false rejections of ordinary phone-scanner and Office output. |
FileContent:InspectCompressedPdfStreams |
true (default). Inflates FlateDecode stream payloads and re-runs name extraction plus the pattern scan. This is what makes RejectPdfObjectStreams=false safe. |
FileContent:MaxDecompressionRatio / MaxPdfStreamScanMilliseconds |
Decompression-bomb guards: per-stream inflate ratio (default 200×) and per-file wall-clock budget for the stream scan (default 2000 ms). |
FileContent:MaxImageWidth / MaxImageHeight / MaxImagePixels |
Pixel-bomb guards only (defaults 30000 / 30000 / 300000000, raised in 3.2.0). FileUpload:MaxFileSizeBytes remains the primary size guard. |
FileContent:RejectEncryptedPdfs / RejectInteractivePdfs |
Reject /Encrypt PDFs (true by default — their contents cannot be scanned) and /AcroForm PDFs (false by default). |
VirusScan:Enabled |
When false, Layer 7 is bypassed. Layers 1–6 + 8 still run. |
VirusScan:FailClosedOnUnavailable |
false (default) accepts the file as Unavailable/NotScanned when the scanner cannot give a verdict; true rejects it. Detection is always fail-closed regardless. |
VirusScan:ClamAv:MaxStreamBytes |
Must align with StreamMaxLength in your clamd.conf. |
Dependencies
Declared by the NuGet package — no manual installation required:
- ASP.NET Core 8, 9, or 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 | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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 && < 4.0.0)
-
net8.0
- Konscious.Security.Cryptography.Argon2 (>= 1.3.1)
- SixLabors.ImageSharp (>= 3.1.11 && < 4.0.0)
-
net9.0
- Konscious.Security.Cryptography.Argon2 (>= 1.3.1)
- SixLabors.ImageSharp (>= 3.1.11 && < 4.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v3.2.1 — documentation patch. No code, IL, or on-disk format change.
AssemblyVersion stays at 3.2.0.0 — drop-in upgrade from 3.2.0.
• CORRECTED CONFIG KEY: the 3.2.0 notes and CHANGELOG documented the new
PDF object-stream switch as FileUpload:ContentValidation:RejectPdfObjectStreams.
The real key is FileContent:RejectPdfObjectStreams — AddSecureFileUpload()
binds FileContentValidatorOptions to the top-level "FileContent" section, so
every property on that options class is configured under that prefix. If you
set the documented key on 3.2.0, it silently did nothing and the default
(false) applied. The default is the recommended value, so no behavior was
lost unless you were deliberately opting IN to rejecting object streams.
• README documentation links are now absolute GitHub URLs. Relative markdown
paths (KNOWN-GAPS.md, SECURITY-ANALYSIS.md, docs/*, in-page anchors) do not
resolve on the NuGet gallery, so every doc link on the package page was dead.
• README now documents the 3.1.0 and 3.2.0 releases, which were missing from
the packaged copy, and corrects stale FileContent defaults in the sample
appsettings block (image dimension caps were still shown as the pre-3.2.0
10000 px / 40 MP values).
v3.2.0 — token-aware PDF lexing, real-world false-rejection fixes.
No public API break. FileContentValidatorOptions gains RejectPdfObjectStreams;
three defaults change, all revertible by config.
This release merges the deep content validator rework from the production
intake application this package was lifted from with the compressed-stream
scanner that only existed here — neither side had both.
• PDF tokens are matched by extracting real name objects and decoding #xx
hex escapes, closing the /J#53 spelling bypass and removing /JSON false
positives. StripPdfStreamPayloads now treats "stream" as a lexical token
rather than a substring: a plain IndexOf matched inside comments, literal
strings, and names like /upstream, discarding the rest of the document
and hiding every dangerous token that followed.
• The FlateDecode compressed-stream scanner is driven by that same lexer
instead of its own substring search, and identifies threats by name
extraction. Object-stream interiors are now inspected with hex-escape
decoding for the first time.
• FileContent:RejectPdfObjectStreams defaults to FALSE. Object streams are standard in
PDF 1.5+, so rejecting them refused output from Word, Acrobat, browser
print, and every mainstream phone scanner app. Coverage does not regress
— their contents are now inflated and name-scanned.
• Post-EOI JPEG data is scanned but no longer rejected for failing to match
a recognized container; the old allowlist refused MPF second images,
Samsung Motion Photo SEF footers, and alignment padding.
• Image dimension caps raised to 30000 px / 300 MP. The previous values
false-rejected ordinary 48–200 MP smartphone photos. These guard against
decompression pixel bombs only.
• Image text threat scans are confined to metadata and comment segments,
so coincidental byte patterns in entropy-coded pixel data no longer
false-reject legitimate photos. Compressed PNG text chunks (zTXt, iTXt)
are inflated before scanning, with output bounds.
• Uploads are written to a temp file and moved into place, so an
interrupted write leaves no partial file. GetDecryptedFileStreamAsync
enforces storage-root containment and rejects reparse points in any path
component. Virus scan outcomes distinguish Disabled from Unavailable.
• SixLabors.ImageSharp pinned to [3.1.11,4.0.0): ImageSharp moved to a
commercial license at v4, which would push that obligation onto every
consumer. The range still admits 3.1.x security patches.
• No change to the 8-layer pipeline order, on-disk envelope formats,
Argon2id KEK derivation, or the legacy PBKDF2 decrypt fallback.
v3.1.0 — fail-closed sanitization, user-bound download tokens, config guards.
No public API break — one optional constructor parameter added to
FileAccessTokenService (DI-resolved; existing call sites compile unchanged).
One deliberate behavioral change, revertible by config:
• Image recompression now FAILS CLOSED (Gap 1). If the sanitizing
re-encode fails, the upload is rejected instead of falling back to the
original bytes — a header that parses but a decode that fails is the
exact shape of a crafted polyglot, and the old fallback silently kept
any appended tail on disk. Restore the pre-3.1.0 behavior with
FileUpload:RejectOnRecompressFailure=false.
• Download tokens can be cryptographically bound to the issuing user:
FileDownload:BindTokensToUser (default false). When enabled, the
authenticated user's identity is folded into the Data Protection
purpose chain — a token replayed by a different account fails
verification, and issuing a token on an unauthenticated request
throws. AddSecureFileUpload() now registers IHttpContextAccessor.
• User-facing error strings no longer echo HTML-active characters
(<, >, quotes) from attacker-controlled filenames — SanitizeForLog
neutralizes them so a consumer rendering FileUploadResult.Errors
without encoding cannot be handed markup.
• Windows Defender scan timeout now clamps with a lower bound of 1s
(matching ClamAV). A configured 0 previously made every scan time out
instantly — silently disabling scanning under fail-open availability.
• ClamAV MaxStreamBytes ≤ 0 now falls back to the 25 MiB default with a
warning instead of failing every scan.
• Supply chain: GitHub Actions pinned to commit SHAs; Dependabot enabled
for nuget + github-actions ecosystems.
• New HardeningV310Tests cover fail-closed/fallback recompression, valid
image round-trip, HTML-neutralized error output, and the token-binding
matrix (same-user resolve, cross-user replay rejection, anonymous
issuance refusal, unbound default round-trip).
• No change to the 8-layer pipeline order, on-disk envelope formats,
Argon2id KEK derivation, or legacy PBKDF2 decrypt fallback.
v3.0.3 — defense-in-depth hardening (backward-compatible patch).
No public API break. AssemblyVersion stays at 3.0.0.0 so this is a drop-in
upgrade from any 3.0.x line.
• Filename validation now NFKC-normalizes before all checks, defeating
fullwidth-Unicode disguises (U+FF0E '.' → '.'), fullwidth reserved
device names (CON.pdf), and fullwidth-disguised double-extensions.
Trailing dots and spaces are rejected (Windows path-strip evasion),
and a 255-character length cap is enforced. Legitimate non-ASCII
filenames (accented Latin, CJK, Cyrillic, Greek, etc.) pass through
unchanged — NFKC is identity on those.
• PDF deep validation gains hard caps against decompression-bomb and
polyglot adversarial inputs: per-stream decompression-ratio cap
(default 200x), per-file wall-clock timeout (default 2000 ms),
bounded nested-stream recursion depth (default 2 for /ObjStm), and
full CancellationToken propagation through the FlateDecode walker.
• Virus-scan availability mode is now an explicit configuration option:
VirusScan:FailClosedOnUnavailable (default false = prior fail-open
behavior; set true to reject the upload when the scanner cannot give
a verdict). Detection mode is always fail-closed regardless of this
flag. A uniform VIRUS_SCAN_SKIPPED security event is emitted in both
modes so operators can alert on a single signal.
• Crypto classification is now explicit: at-rest encryption is classical
AES-256-GCM (quantum-tolerant by key size for confidentiality, but no
PQ asymmetric layer). New SECURITY.md states the posture and the
deliberate separation from the PostQuantum.* family.
• New HardeningRegressionTests cover the filename evasion matrix (with
positive coverage for legitimate accented/CJK/Cyrillic/Greek names),
decompression-bomb rejection within the time budget, nested
FlateDecode recursion, cancellation propagation, fail-closed AV mode,
concurrent encrypted uploads, and PathHelper.IsPathUnderBase
resistance to encoded separators. Fuzz harness gains a triage
assertion that treats any "Allowed" verdict on a curated seed under
tests/Fuzz/seeds/ as a finding.
• No change to the 8-layer pipeline order, the v2 envelope encryption
format, the Argon2id KEK derivation, the PBKDF2 legacy decrypt
fallback, or the plaintext/DEK/KDF-input zeroing discipline.
v3.0.2 — multi-targeting restored (net8.0; net9.0; net10.0).
No behavioral, on-disk-format, or crypto-posture changes from 3.0.1. The
3.0.0 / 3.0.1 packages narrowed the target framework to net10.0 only,
which forced consumers on currently-supported .NET 8 LTS or .NET 9 STS
runtimes to either pin to the 2.x line or upgrade their host. This
release re-publishes the same source as a multi-targeted package so any
supported .NET runtime can take 3.x.
• TargetFrameworks: net8.0;net9.0;net10.0.
• No conditional compilation needed — the entire pipeline already
compiled against the .NET 8 BCL (AesGcm two-arg constructor,
Rfc2898DeriveBytes.Pbkdf2 static, Encoding.Latin1, UTF-8 string
literals, Konscious Argon2id).
• AssemblyVersion stays at 3.0.0.0 so this is a drop-in upgrade with
no binding-redirect change required.
• README, install instructions, and badges updated to reflect the
restored multi-target support.
v3.0.1 — documentation and code-hygiene patch.
No behavioral or breaking changes. Safe in-place upgrade from 3.0.0.
• README: new "Deployment notes" section covering the multi-instance Data
Protection key-persistence requirement (SetApplicationName +
PersistKeysToFileSystem / Azure Blob / KMS-backed key store) so download
tokens validate across replicas. Without this, load-balanced staff
requests can intermittently fail with DOWNLOAD_REJECTED_BAD_TOKEN.
• README: token-replay window explicitly documented along with the
mitigations already in the library (no-store cache, Referrer-Policy,
[Authorize], short default lifetime).
• README: small markdown-rendering fixes in the "Issue a download token"
sample so the example renders cleanly on the NuGet gallery page.
• Pruned the unused SanitizeForLog helper from SecureFileDownloadController
(left over from the pre-3.0.0 path-based input — opaque tokens don't
need user-input sanitization).
• AssemblyVersion stays at 3.0.0.0 so this is a drop-in upgrade with no
binding-redirect change required.
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.