I thought file uploads were simple… until I saw how often they aren't. This article shows a practical .NET baseline that covers common upload mistakes and extension spoofing: extension allowlisting, size limits, and signature (magic number) validation - plus what to add next if you need stronger assurance.
At some point, most of us build "a simple file upload". And most of the time, it works.
Until you realize that in a lot of applications, you can rename malicious.exe to holiday-photo.jpg and upload it without so much as a raised eyebrow.
I'm an experienced .NET developer slowly transitioning into AppSec, and file uploads were one of those topics that looked straightforward… right up until I started reading what can actually go wrong (Tanya Janca's Alice and Bob books deserve a shout-out here).
OWASP has great coverage of the broader risks and mitigation mechanisms in their Cheat Sheet series. What I want to do in this article is stay practical: show a layered validation approach (extensions, size, signature) and explain why each layer matters.
ℹ️ Disclaimer: All code examples throughout this article are written in .NET 10. The same rules apply for .NET Framework, though the implementation might differ slightly.
Validation strategies
File uploads are one of those features where "it works" is often treated as "it's fine". The problem is that potential attackers don't care if it works, they just care what they can slip through.
The approach I've decided to go with is layered validation. We do the cheap checks first and deeper checks only when needed. In this article, I'll cover three baseline validations that you can implement in .NET today:
- Validation by extensions
- Validation by size
- Validation by file signature
This won't make upload "bulletproof", but it dramatically reduces risk compared to "just accept the file and hope". Think of it as a minimum baseline before you add deeper measures like format conformance and malware scanning.
Validation by extension
Let's start with the simplest check of the bunch: validating the file extension.
⚠️ Warning: This is the easiest validation step to bypass, because the filename is user-controlled. Still, it's worth doing as a first step because it's cheap, fast, and it helps you enforce an explicit allowlist of what your application is even willing to accept.
First, decide which file types your application should allow. For this example, we'll accept: .jpg, .png, .bmp, and .pdf.
To keep things organized, we'll define a FileDefinition class and a FileValidator that knows about supported file types. Later we'll expand this with things like signatures (magic numbers).
public class FileDefinition
{
public string FileType { get; set; }
}
public class FileValidator
{
private static readonly List<FileDefinition> SupportedFileDefinitions = new()
{
new FileDefinition()
{
FileType = ".jpg"
},
new FileDefinition()
{
FileType = ".png"
},
new FileDefinition()
{
FileType = ".bmp"
},
new FileDefinition()
{
FileType = ".pdf"
}
}
}
using System.IO;
public class FileValidator
{
// ...
public bool IsValidFileType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var isSupported = SupportedFileDefinitions
.Any(fd => fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase));
return isSupported;
}
}
When we call Path.GetExtension(fileName), we get the extension including the leading dot (for example .jpg). We normalize it and check whether it exists in our list of supported types.
But there's an important distinction here: supported isn't the same as allowed. A validator might know how to validate a lot of formats if reused across multiple applications, but each application should explicitly decide that it accepts. That's why we introduce configuration, and why the secure defaults accept nothing until configured.
public class FileValidatorConfiguration
{
public List<string> SupportedFileTypes { get; set; } = new();
}
public class FileValidator
{
//...
private readonly FileValidatorConfiguration _configuration;
public FileValidator(FileValidatorConfiguration configuration)
{
_configuration = configuration;
}
public bool IsValidFileType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var isSupported = _configuration.SupportedFileTypes.Contains(extension, StringComparer.InvariantCultureIgnoreCase) &&
SupportedFileDefinitions.Any(fd => fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase));
return isSupported;
}
}
At this point we're only validating the filename, not the file content. Next we'll add a size limit which is another cheap check that prevents abuse and sets a clear boundary.
Validation by size
Next up: file size.
Size limits are one of those validations that sound boring, until you wish you had them. Without a hard limit, you're inviting resource abuse: huge uploads, slow requests, memory pressure, storage spikes, and (in the worst cases) denial-of-service attacks.
The good news is that this check is cheap and easy. We simply decide what "reasonable" looks like for our use case and reject anything above that. For this example, I'll default to 50 MB.
public class FileValidatorConfiguration
{
public List<string> SupportedFileTypes { get; set; } = new();
public long FileSizeLimit { get; set; } = 52_428_800; // -> New property
}
using System.IO;
public class FileValidator
{
// ...
public bool HasValidSize(Stream stream)
{
if(!stream.CanSeek) throw new InvalidOperationException();
return stream.Length <= _configuration.FileSizeLimit;
}
}
At this point we've validated the filename and the size, both helpful, but neither tells us what the file actually is. Next we'll validate the file signature (magic numbers), which is where things get much more meaningful (and technically interesting).
Validation by file signature
So far we've only looked at the metadata: the filename and the size. Both are useful, but neither tells us what the file actually is.
This is where file signatures come in.
File signatures (often referred to as magic numbers) are byte sequences at the beginning of the file that identify its format. Instead of trusting that a file claims to be a .jpg, we can inspect the first bytes of the uploaded file content and verify that it matches what a JPEG (or PNG, BMP, PDF, etc.) should look like.
This is one of the most practical ways to reduce extension spoofing attacks: renaming malicious.exe to holiday-photo.jpg might fool an extension check, but it won't pass a signature check.
We'll extend our FileDefinition class to include the valid signatures for each file type, then implement a HasValidSignature(...) method that reads the header bytes and check for a match.
public class FileDefinition
{
public string FileType { get; set; }
public List<byte[]> ValidSignatures { get; set; }
}
private static readonly List<FileDefinition> SupportedFileDefinitions = new()
{
new FileDefinition()
{
FileType = ".jpg",
ValidSignatures = new List<byte[]>
{
new byte[] { 0xFF, 0xD8, 0xFF }
}
},
new FileDefinition()
{
FileType = ".png",
ValidSignatures = new List<byte[]>
{
new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } // ‰PNG␍␊␚␊
}
},
new FileDefinition()
{
FileType = ".bmp",
ValidSignatures = new List<byte[]>
{
new byte[] { 0x42, 0x4D } // BM
}
},
new FileDefinition()
{
FileType = ".pdf",
ValidSignatures = new List<byte[]>
{
new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D } // %PDF-
}
}
}
using System.IO;
public class FileValidator
{
// ...
public bool HasValidSignature(string fileName, Stream stream)
{
if(!stream.CanSeek) throw new InvalidOperationException();
// Retrieve the original position in the stream
var originalPosition = stream.Position;
stream.Seek(0, SeekOrigin.Begin);
try
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var fileDefinition = SupportedFileDefinitions.FirstOrDefault(fd =>
fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase));
if (fileDefinition is null) return false;
// Get the maximum possible length of header bytes from the definition
var signatureLength = fileDefinition.ValidSignatures.Max(s => s.Length);
// Check whether the content is valid according to the primary header signature length
if (stream.Length < signatureLength) return false;
byte[] headerBytes = new byte[signatureLength];
stream.ReadExactly(headerBytes, 0, headerBytes.Length);
var result = fileDefinition.ValidSignatures
.Any(signature => headerBytes.AsSpan(0, signature.Length).SequenceEqual(signature));
return result;
}
finally
{
// Restore original position in the stream for further processing
stream.Seek(originalPosition, SeekOrigin.Begin);
}
}
}
PDF signature
PDFs are a little special. While many formats have signature right at byte 0 (some have an offset of 4 bytes), PDF files can contain leading bytes before the %PDF- marker. In practice, it's common to find the signature within the first 1024 bytes rather than at the very start.
Because of that, a naive "read the first N bytes" approach can reject PDF files. To handle this, we'll add a small PDF-specific validator that searches for %PDF- within the first 1024 bytes.
using System.Text;
internal static class PdfValidator
{
private const int SignatureCheckLength = 1024;
private const string ValidSignatureString = "%PDF-";
internal static bool IsValidPdfSignature(Stream stream)
{
if(!stream.CanSeek) throw new InvalidOperationException();
stream.Seek(0, SeekOrigin.Begin);
// Only read the necessary bytes into a new byte[].
var headerBytes = new byte[SignatureCheckLength];
var length = stream.Read(headerBytes, 0, headerBytes.Length);
// Look for the valid signature.
var headerContent = Encoding.ASCII.GetString(headerBytes, 0, length);
var isValid = headerContent.Contains(ValidSignatureString, StringComparison.Ordinal);
// If not found (we didn't find %PDF- anywhere within the first 1024 bytes) this is not a valid PDF.
return isValid;
}
}
using System.IO;
public class FileValidator
{
// ...
public bool HasValidSignature(string fileName, Stream stream)
{
if(!stream.CanSeek) throw new InvalidOperationException();
// Retrieve the original position in the stream
var originalPosition = stream.Position;
stream.Seek(0, SeekOrigin.Begin);
try
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var fileDefinition = SupportedFileDefinitions.FirstOrDefault(fd =>
fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase));
if (fileDefinition is null) return false;
// As PDF documents are somewhat special in terms of both signature validation,
// we need to investigate these files further. The PdfValidator is made specifically
// for this purpose.
if (fileDefinition.FileType.Equals(".pdf", StringComparison.InvariantCultureIgnoreCase))
{
return PdfValidator.IsValidPdfSignature(stream);
}
// Get the maximum possible length of header bytes from the definition
var signatureLength = fileDefinition.ValidSignatures.Max(s => s.Length);
// Check whether the content is valid according to the primary header signature length
if (stream.Length < signatureLength) return false;
byte[] headerBytes = new byte[signatureLength];
stream.ReadExactly(headerBytes, 0, headerBytes.Length);
var result = fileDefinition.ValidSignatures
.Any(signature => headerBytes.AsSpan(0, signature.Length).SequenceEqual(signature));
return result;
}
finally
{
// Restore original position in the stream for further processing
stream.Seek(originalPosition, SeekOrigin.Begin);
}
}
}
At this point we're validating something much harder to fake than a filename. Still, signature checks are not a guarantee that a file is "safe", they mainly help confirm format identity. For higher assurance, you'd typically add deeper checks such as format conformance validation (e.g. OpenXML/OpenDocument Format structure) and malware scanning before saving anything to disk.
Next, we'll combine the checks into a single IsValidFile(...) method so the upload flow stays simple.
Putting it together
At this point we have three separate checks: extension allowlisting, file size limits, and signature validation. That's great for learning what each layer does, but in a real codebase you probably don't want every upload endpoint to call three methods in the right order.
So instead, we'll add a single entry point: IsValidFile(...). It runs validations in a sensible sequence (cheap checks first), and gives us one method to call from controllers or services.
public class FileValidator
{
// ...
public bool IsValidFile(string fileName, Stream stream)
{
// Validate file type.
if (!IsValidFileType(fileName)) return false;
// Validate file size.
if (!HasValidSize(stream)) return false;
// Validate file signature.
if (!HasValidSignature(fileName, stream)) return false;
return true;
}
}
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
using var fileContentStream = file.OpenReadStream();
var configuration = new FileValidatorConfiguration
{
SupportedFileTypes = [".jpg", ".png", ".bmp", ".pdf"]
};
var validator = new FileValidator(configuration);
// Validate extensions, size, and signature
if (!validator.IsValidFile(file.FileName, fileContentStream))
{
return BadRequest("Invalid or unsupported file.");
}
// Proceed with processing/saving...
return Ok();
}
From the caller's perspective, the upload flow stays clean: validate, then proceed with whatever processing or storage your application needs.
Conclusion
We've now built a small, layered validator that checks three things:
- The extension is explicitly allowed
- The file size stays within a reasonable limit
- The file signature matches the format the file claims to be
This won't make file uploads "safe" by itself, but it does eliminate a lot of common mistakes and reduces the risk of trivial bypasses like extension spoofing. In other words: it's a practical baseline.
If you want to go further, there are a few strong next steps depending on what you accept and how high your risk is:
- Format conformance validation for archive-based formats (e.g. OpenXML/OpenDocument Format)
- Malware scanning before persisting the file
- Safe storage and handling (randomized filenames, restricted directories, no direct execution, and careful processing of user content)
Defense-in-depth is the goal: every layer reduces the chance that a single mistake turns into an accident.
An open-source alternative
If you'd rather not build and maintain file validation from scratch, ByteGuard.FileValidator is part of a broader open-source initiative: ByteGuard HQ: a growing family of practical AppSec-focused packages.
The goal with ByteGuard HQ is simple: ship small, well-documented security building blocks that are easy to adopt and hard to misuse.
Try it:
dotnet add package ByteGuard.FileValidator
- NuGet (all packages): https://www.nuget.org/profiles/ByteGuard
- GitHub org: https://github.com/ByteGuard-HQ
If you want to follow along, give feedback, report edge cases, or contribute ideas, you're welcome to join the ByteGuard HQ Discord. It's a small community, but the intention is clear: AppSec nerds helping each other build safer software and turning real-world security guidance into practical tooling
Top comments (0)