CWE-73: External Control of File Name or Path - C#
Overview
External control of file names or paths happens when user-supplied input is used to construct a file system path. C#/.NET applications reach the file system through System.IO, which joins and canonicalizes paths but never confines them: keeping a resolved path under a base directory is left to the caller.
Primary Defence: Use Path.GetFullPath() with Path.GetRelativePath() containment validation that rejects rooted or parent-relative results. Allowlists for known file sets and GUID-based indirect reference maps for sensitive files reduce path traversal, absolute path injection, and symlink risks. For uploads, store under a server-generated name such as Path.GetRandomFileName() and reject any client-supplied name containing / or \ outright - do not rely on Path.GetFileName() to strip components, because it recognises only the running platform's separators and leaves a backslash path intact on Unix-based .NET. Store the supplied name exactly as-is for display metadata.
Common Vulnerable Patterns
Direct Use of User Input in File Operations
// VULNERABLE - No validation of user-supplied filename
[HttpGet("download")]
public IActionResult DownloadFile(string filename)
{
// User can provide any path
var content = System.IO.File.ReadAllBytes(filename);
return File(content, "application/octet-stream");
}
// Attack example:
// GET /download?filename=C:\Windows\System32\config\SAM
// GET /download?filename=../../../appsettings.json
// Result: Reads sensitive files from the server
Why this is vulnerable: There is no base directory here for an attacker to escape from - the request parameter is the path. File.ReadAllBytes() accepts an absolute path as readily as a relative one, so no traversal sequence is required and a .. denylist would catch nothing. The result is streamed straight back to the caller, which makes the endpoint a general read primitive for every file the worker process identity can open: connection strings in appsettings.json, data-protection keys, certificate stores.
Validating Before the Final Decode
// VULNERABLE - the check runs on a value that is decoded again afterwards
[HttpGet("read")]
public IActionResult ReadFile(string filename)
{
// Model binding has already decoded the query value once
if (filename.Contains(".."))
{
return BadRequest("Invalid filename");
}
// A second decode restores the sequence the check rejected
var decoded = Uri.UnescapeDataString(filename);
var path = Path.Combine(@"C:\app\data", decoded);
var content = System.IO.File.ReadAllText(path);
return Content(content);
}
// Attack example:
// GET /read?filename=%252e%252e%255c%252e%252e%255cappsettings.json
// Model binding yields "%2e%2e%5c%2e%2e%5cappsettings.json" - no literal ".."
// Uri.UnescapeDataString yields @"..\..\appsettings.json"
// Result: reads outside C:\app\data
Why this is vulnerable: Model binding decodes query and route values, so a singly-encoded ..%5C..%5C arrives as ..\..\ and this check would catch it. The denylist fails when something decodes a second time after validation, and it never sees the payloads that carry no .. at all - an absolute path, or a directory junction inside the allowed folder. Validate the path the filesystem will use, not the string the request carried.
Not Using Path.GetFullPath for Validation
// VULNERABLE - Path.Combine doesn't prevent absolute paths
[HttpGet("file/{*filename}")]
public IActionResult GetFile(string filename)
{
// Path.Combine allows absolute paths if second arg is absolute
var fullPath = Path.Combine(@"C:\app\data", filename);
var content = System.IO.File.ReadAllBytes(fullPath);
return File(content, "application/octet-stream");
}
// Attack example:
// GET /file/C:\Windows\win.ini
// Result: Path.Combine(@"C:\app\data", @"C:\Windows\win.ini") = @"C:\Windows\win.ini"
Why this is vulnerable: Path.Combine() is documented to discard everything before a rooted segment, so it is doing exactly what it promises - the mistake is treating a join as a boundary. The catch-all route template {*filename} compounds it by accepting slashes that a plain {filename} segment would not, so the parameter can carry a whole path.
Path.GetFullPath() on its own does not fix this either. It canonicalizes, which is necessary, but it confines nothing: GetFullPath(@"C:\Windows\win.ini") returns that path unchanged and quite happily. The missing half is the comparison against the base directory afterwards.
Sanitizing the Filename but Nothing Else
// VULNERABLE - traversal is contained, but nothing else about the write is
[HttpPost("upload")]
public async Task<IActionResult> UploadFile(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest("No file uploaded");
// Strips directory components, and that is the only control applied
var filename = Path.GetFileName(file.FileName);
var path = Path.Combine(@"C:\uploads", filename);
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
return Ok();
}
// Path.GetFileName(@"..\..\wwwroot\malicious.html") returns "malicious.html", so
// traversal is contained. What is not:
// - Uploading "web.config", or the name of an existing report, overwrites it silently
// - No extension or content-type allowlist, so ".aspx" and ".exe" are accepted
// - No authorization check on who may write into C:\uploads
// - No size limit, so a single request can fill the volume
Why this is vulnerable: This shape is worth recognising because of how it usually ends: the traversal rule stops firing, the finding is closed, and every remaining problem belongs to a different CWE that nobody has opened a ticket for. FileMode.Create is the truncating mode, so an upload named after an existing file replaces it without an error; an attacker who can pick the name picks which file to destroy, and on a directory served by the application, which file to add.
Deletion With the Path Built by Concatenation
// VULNERABLE - Direct string concatenation for paths
[HttpDelete("delete")]
public IActionResult DeleteFile(string filename)
{
var filepath = @"C:\app\temp\" + filename;
System.IO.File.Delete(filepath);
return Ok();
}
// Attack example:
// DELETE /delete?filename=..\..\important.dll
// Result: Deletes C:\important.dll
Why this is vulnerable: Concatenation leaves the .. in the string, and File.Delete() resolves it like any other API - C:\app\temp\..\..\important.dll is C:\important.dll. Delete endpoints deserve their own attention because the damage does not depend on reading anything back: there is no output to inspect, no content-type to get wrong, and the request either succeeded or it did not. A traversal that would be a moderate disclosure on a download route is availability loss here, and it is not recoverable by patching afterwards.
Secure Patterns
Allowlist with Path Validation (Most Secure)
public class SecureFileService
{
private readonly string _baseDirectory;
private readonly HashSet<string> _allowedFiles;
public SecureFileService(string baseDirectory)
{
_baseDirectory = Path.GetFullPath(baseDirectory);
_allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"report.pdf",
"summary.txt",
"data.csv"
};
}
public byte[] ReadFile(string filename)
{
if (!_allowedFiles.Contains(filename))
throw new UnauthorizedAccessException("File not allowed");
var filePath = Path.GetFullPath(Path.Combine(_baseDirectory, filename));
// Robust containment: compute relative path
var relative = Path.GetRelativePath(_baseDirectory, filePath);
if (Path.IsPathRooted(relative) ||
relative == ".." ||
relative.StartsWith(".." + Path.DirectorySeparatorChar) ||
relative.StartsWith(".." + Path.AltDirectorySeparatorChar))
throw new UnauthorizedAccessException("Path traversal detected");
if (!File.Exists(filePath))
throw new FileNotFoundException("File not found", filename);
return File.ReadAllBytes(filePath);
}
}
// Usage:
var service = new SecureFileService(@"C:\app\data");
var content = service.ReadFile(userInput);
Why this works:
- Access is restricted to an exact allowlist of approved filenames.
- The target path is canonicalized with
Path.GetFullPath()before validation. - Containment is enforced using a relative-path check (
Path.GetRelativePath), avoiding string-prefix bypasses. - With appropriate filesystem permissions (base directory not attacker-writable), this prevents path traversal and absolute-path injection.
Path Canonicalization with Ancestor Validation (Flexible and Secure)
public class SecurePathValidator
{
private readonly string _baseDirectory;
public SecurePathValidator(string baseDirectory)
{
// Get absolute, normalized base directory path
_baseDirectory = Path.GetFullPath(baseDirectory);
if (!Directory.Exists(_baseDirectory))
{
throw new DirectoryNotFoundException(
$"Base directory not found: {baseDirectory}");
}
}
public string ValidatePath(string userPath)
{
if (string.IsNullOrWhiteSpace(userPath))
throw new ArgumentException("Path cannot be empty", nameof(userPath));
var full = Path.GetFullPath(Path.Combine(_baseDirectory, userPath));
var rel = Path.GetRelativePath(_baseDirectory, full);
if (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
throw new UnauthorizedAccessException($"Path traversal detected: {userPath}");
return full;
}
}
// Usage:
var validator = new SecurePathValidator(@"C:\app\data");
var safePath = validator.ValidatePath(userInput);
var content = File.ReadAllText(safePath);
Why this works:
Path.GetFullPath()collapses./..and normalizes an absolute path before validation.- Containment is enforced with
Path.GetRelativePath(), avoiding string-prefix bypasses (e.g.,C:\base_evil). - This prevents directory traversal and absolute-path injection by ensuring the final path remains under the trusted base directory.
- If the base directory is attacker-writable, additional defenses may be needed against symlink/junction (reparse point) attacks.
GUID-Based Indirect References
public class SecureFileRegistry
{
private readonly string _baseDirectory;
private readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, string> _registry;
public SecureFileRegistry(string baseDirectory)
{
_baseDirectory = Path.GetFullPath(baseDirectory);
_registry = new System.Collections.Concurrent.ConcurrentDictionary<Guid, string>();
}
public Guid RegisterFile(string internalPath)
{
var full = Path.GetFullPath(Path.Combine(_baseDirectory, internalPath));
var rel = Path.GetRelativePath(_baseDirectory, full);
if (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
throw new UnauthorizedAccessException($"Invalid file path: {internalPath}");
if (!File.Exists(full))
throw new FileNotFoundException($"File not found: {internalPath}");
var token = Guid.NewGuid();
_registry[token] = full;
return token;
}
public byte[] GetFile(Guid token)
{
if (!_registry.TryGetValue(token, out var full))
throw new FileNotFoundException("Invalid file token");
return File.ReadAllBytes(full);
}
}
// Usage:
var registry = new SecureFileRegistry(@"C:\app\data");
var token = registry.RegisterFile(@"reports\2024\q1.pdf");
// Return token to user, they can only access via this token
var content = registry.GetFile(token);
Why this works:
- Users receive opaque GUID tokens instead of filesystem paths.
- The server resolves and validates requested paths under a trusted base directory before registering them.
- After registration, file access uses the server-side mapping, so user input cannot influence path resolution at read time.
- Tokens should be scoped (user/tenant) and time-limited to reduce the impact of token leakage; GUIDs alone are not authorization.
Secure Filename Sanitization
// Sanitizes and validates filenames
public class SecureFilenameHandler
{
private static readonly HashSet<string> AllowedExtensions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".pdf", ".txt", ".csv", ".xlsx"
};
private static readonly char[] InvalidChars =
Path.GetInvalidFileNameChars();
public static string SanitizeFilename(string filename)
{
if (string.IsNullOrWhiteSpace(filename))
{
throw new ArgumentException(
"Filename cannot be empty", nameof(filename));
}
// Reject path syntax rather than stripping it. Path.GetFileName()
// uses the separators of the running platform, so on Unix-based .NET
// a backslash is an ordinary filename character and survives.
if (filename.IndexOfAny(new[] { '/', '\\' }) >= 0)
{
throw new ArgumentException(
"Filename must not contain path separators", nameof(filename));
}
if (filename == "." || filename == ".." ||
filename != Path.GetFileName(filename))
{
throw new ArgumentException(
"Invalid filename", nameof(filename));
}
// Remove invalid characters
var sanitized = string.Join("_",
filename.Split(InvalidChars, StringSplitOptions.RemoveEmptyEntries));
// Validate extension
var extension = Path.GetExtension(sanitized);
if (!AllowedExtensions.Contains(extension))
{
throw new ArgumentException(
$"File type not allowed: {extension}");
}
return sanitized;
}
}
// Usage:
var safeFilename = SecureFilenameHandler.SanitizeFilename(userInput);
var filePath = Path.Combine(@"C:\uploads", safeFilename);
Why this works (and what it doesn't do):
- Rejecting
/and\explicitly is what carries this across platforms.Path.GetFileName()splits onPath.DirectorySeparatorCharandPath.AltDirectorySeparatorChar, which on Unix-based .NET are both/; Microsoft documents that backslash is a valid filename character there.Path.GetInvalidFileNameChars()has the same asymmetry - on Unix it returns only\0and/. Without the separator check,..\..\etc\cron.d\job.pdfwould pass through both untouched: inert on Linux, and a path the moment that name reaches a Windows host or an SMB share. - The
./..and round-trip checks reject what a separator check alone lets through - directory references, and anythingGetFileName()still parses as a path on the running platform, such as the drive-relativeC:report.pdfon Windows. - Invalid characters are removed to produce an OS-compatible name.
- Only the four allowlisted extensions are accepted; any other suffix throws.
- This is filename hygiene only; you must still enforce real-path containment and safe file handling (e.g., prevent symlink/junction escapes and overwrites) when opening or writing files. For uploads, ASP.NET Core's own guidance is not to reuse the client's name for storage at all: save under a server-generated name such as
Path.GetRandomFileName()and store the supplied name exactly as-is for display metadata.
Framework-Specific Guidance
ASP.NET Core - Secure File Upload and Download
// ASP.NET Core upload/download with robust path checks + streaming
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text.RegularExpressions;
[ApiController]
[Route("api/files")]
public class SecureFileController : ControllerBase
{
private readonly string _uploadBase; // canonical full path
private readonly long _maxFileSize = 10 * 1024 * 1024; // 10MB
private readonly ILogger<SecureFileController> _logger;
private static readonly HashSet<string> AllowedExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".pdf", ".txt", ".csv", ".xlsx" };
private static readonly HashSet<string> AllowedContentTypes =
new(StringComparer.OrdinalIgnoreCase)
{
"application/pdf",
"text/plain",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
};
private static readonly Regex DownloadNamePattern =
new(@"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.(pdf|txt|csv|xlsx)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public SecureFileController(IWebHostEnvironment env, ILogger<SecureFileController> logger)
{
_logger = logger;
var uploadPath = Path.Combine(env.ContentRootPath, "uploads");
Directory.CreateDirectory(uploadPath);
// Canonicalize once
_uploadBase = Path.GetFullPath(uploadPath);
}
[HttpPost("upload")]
[RequestSizeLimit(10_000_000)]
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length <= 0)
return BadRequest("No file provided");
if (file.Length > _maxFileSize)
return BadRequest("File too large");
// Advisory/policy check only (not a file-signature guarantee)
if (string.IsNullOrWhiteSpace(file.ContentType) || !AllowedContentTypes.Contains(file.ContentType))
return BadRequest($"File type not allowed: {file.ContentType}");
var originalName = Path.GetFileName(file.FileName);
if (string.IsNullOrWhiteSpace(originalName))
return BadRequest("Invalid filename");
var ext = Path.GetExtension(originalName);
if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext))
return BadRequest($"File extension not allowed: {ext}");
// Server-generated storage name (prevents collisions/path injection)
var storedName = $"{Guid.NewGuid()}{ext.ToLowerInvariant()}";
// Build + canonicalize full target path
var targetFullPath = Path.GetFullPath(Path.Combine(_uploadBase, storedName));
// Robust containment check (avoids string-prefix bypass)
var rel = Path.GetRelativePath(_uploadBase, targetFullPath);
if (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
return BadRequest("Invalid file path");
try
{
// CreateNew avoids overwriting anything unexpectedly
await using var fs = new FileStream(
targetFullPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true
);
await file.CopyToAsync(fs, ct);
return Ok(new { filename = storedName });
}
catch (IOException ioEx)
{
// Handle rare collisions / filesystem issues without leaking details
_logger.LogWarning(ioEx, "Upload failed for {StoredName}", storedName);
return StatusCode(500, "Upload failed");
}
catch (OperationCanceledException)
{
return StatusCode(499); // client closed request (commonly used)
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected upload failure");
return StatusCode(500, "Upload failed");
}
}
[HttpGet("download/{filename}")]
public IActionResult DownloadFile(string filename)
{
if (string.IsNullOrWhiteSpace(filename) || !DownloadNamePattern.IsMatch(filename))
return BadRequest("Invalid filename format");
// TODO: enforce authorization here (owner/role/tenant checks).
// e.g., validate the requesting user is allowed to access this filename/token.
var fullPath = Path.GetFullPath(Path.Combine(_uploadBase, filename));
var rel = Path.GetRelativePath(_uploadBase, fullPath);
if (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
return BadRequest("Invalid file path");
if (!System.IO.File.Exists(fullPath))
return NotFound("File not found");
return PhysicalFile(fullPath, "application/octet-stream", filename);
}
}
Why this works:
- Files are stored under a fixed server-controlled directory using server-generated GUID filenames (no user-controlled paths).
- Extensions are allowlisted and downloads only accept a strict GUID+extension format, reducing path manipulation and guessing.
- Paths are canonicalized with
Path.GetFullPath()and validated for containment using a relative-path check, avoiding string-prefix bypasses. - Upload size limits reduce resource-exhaustion risk; content-type checks are an additional policy filter (not a strong type guarantee).
- Downloads should still enforce authorization; unguessable filenames reduce risk but are not access control.
ASP.NET Core - Static File Serving Configuration
using Microsoft.Extensions.FileProviders;
public class Startup
{
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(env.ContentRootPath, "wwwroot")),
RequestPath = "/static",
ServeUnknownFileTypes = false,
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers["X-Content-Type-Options"] = "nosniff";
// Optional: cache static assets
// ctx.Context.Response.Headers["Cache-Control"] = "public,max-age=31536000,immutable";
}
});
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());
}
}
Why this works:
- Static content is served only from a fixed directory (
wwwroot) via aPhysicalFileProvider, reducing the chance of exposing application files. - Requests are scoped to a dedicated URL prefix (
/static), separating asset URLs from application routes. - Unknown file types are not served (
ServeUnknownFileTypes = false), reducing exposure of unexpected file extensions. X-Content-Type-Options: nosniffhelps prevent browsers from MIME-sniffing responses into executable types.UseStaticFilesserves files but never lists directory contents. Directory browsing is opt-in throughUseDirectoryBrowser()orUseFileServer(enableDirectoryBrowsing: true), and this configuration calls neither.
Entity Framework Core - Secure File Metadata Storage
using Microsoft.EntityFrameworkCore;
public class FileMetadata
{
public Guid Id { get; set; }
public string OriginalFilename { get; set; } = "";
public string StoredFilename { get; set; } = "";
public string ContentType { get; set; } = "";
public long Size { get; set; }
public DateTime UploadedAt { get; set; }
public string UploadedBy { get; set; } = "";
}
public class SecureFileRepository
{
private static readonly HashSet<string> AllowedExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".pdf", ".txt", ".csv", ".xlsx" };
private readonly ApplicationDbContext _context;
private readonly string _storageBase; // canonical
private readonly string _storageBaseWithSep;
public SecureFileRepository(ApplicationDbContext context, string storagePath)
{
_context = context;
_storageBase = Path.GetFullPath(storagePath);
Directory.CreateDirectory(_storageBase);
_storageBaseWithSep = _storageBase.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
}
public async Task<FileMetadata> StoreFileAsync(
string originalFilename,
Stream fileStream,
string contentType,
string userId,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(userId))
throw new UnauthorizedAccessException("Missing user context");
var safeOriginal = Path.GetFileName(originalFilename ?? "");
if (string.IsNullOrWhiteSpace(safeOriginal))
throw new ArgumentException("Invalid filename", nameof(originalFilename));
var ext = Path.GetExtension(safeOriginal);
if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext))
throw new ArgumentException($"File extension not allowed: {ext}");
// Server-controlled stored name
var storedFilename = $"{Guid.NewGuid():D}{ext.ToLowerInvariant()}";
var fullPath = Path.GetFullPath(Path.Combine(_storageBase, storedFilename));
// Robust containment (avoids prefix bypass)
var rel = Path.GetRelativePath(_storageBase, fullPath);
if (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
throw new UnauthorizedAccessException("Invalid storage path");
try
{
await using var outStream = new FileStream(
fullPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await fileStream.CopyToAsync(outStream, ct);
}
catch
{
// Best-effort cleanup
if (System.IO.File.Exists(fullPath))
System.IO.File.Delete(fullPath);
throw;
}
var size = new FileInfo(fullPath).Length;
var metadata = new FileMetadata
{
Id = Guid.NewGuid(),
OriginalFilename = safeOriginal,
StoredFilename = storedFilename,
ContentType = contentType ?? "application/octet-stream", // advisory
Size = size,
UploadedAt = DateTime.UtcNow,
UploadedBy = userId
};
_context.Files.Add(metadata);
await _context.SaveChangesAsync(ct);
return metadata;
}
public async Task<(string fullPath, string downloadName, string contentType)> GetFilePathAsync(
Guid fileId,
string userId,
CancellationToken ct = default)
{
var metadata = await _context.Files.AsNoTracking().FirstOrDefaultAsync(f => f.Id == fileId, ct);
if (metadata == null)
throw new FileNotFoundException("File not found");
// Authorization example: owner-only
if (!string.Equals(metadata.UploadedBy, userId, StringComparison.Ordinal))
throw new UnauthorizedAccessException("Access denied");
var fullPath = Path.GetFullPath(Path.Combine(_storageBase, metadata.StoredFilename));
var rel = Path.GetRelativePath(_storageBase, fullPath);
if (Path.IsPathRooted(rel) ||
rel == ".." ||
rel.StartsWith(".." + Path.DirectorySeparatorChar) ||
rel.StartsWith(".." + Path.AltDirectorySeparatorChar))
throw new UnauthorizedAccessException("Invalid file path");
if (!System.IO.File.Exists(fullPath))
throw new FileNotFoundException("File not found");
return (fullPath, metadata.OriginalFilename, metadata.ContentType);
}
}
Why this works:
- Files are stored using server-generated GUID filenames, so user input never becomes a filesystem path.
- The database is the authoritative mapping from file IDs to stored filenames (indirect reference pattern).
- Original filenames are never used as a path: they are stored as metadata and reduced with
Path.GetFileName()for the download name only. That call is display hygiene rather than a boundary here - on Unix-based .NET it would not strip a backslash-separated prefix - which is safe because the stored path is the GUID, not this value. - Paths are canonicalized and validated for containment using a relative-path check, avoiding string-prefix bypasses.
- Authorization can be enforced at the metadata layer (e.g.,
UploadedBy) before any filesystem access.
Common Pitfalls
- Calling
Path.GetFileName(input)to check for traversal characters, then passing the originalinput(not the sanitized basename) to the file operation. On Windows,..\..\windows\system32\configpasses a "no traversal in the filename" check because the check ran against"config", while the untouchedinputstill resolves through..\..\when it reachesFile.ReadAllBytes(); on Linux the same happens with../../etc/passwd. Which separatorsGetFileName()recognises is platform-dependent - Microsoft documents backslash as a legal filename character on Unix-based .NET - so reject/and\explicitly, and operate on the value you validated rather than the one you started with. - Allowlisting the file extension (
.pdf,.txt) while still accepting an attacker-supplied path for the rest of the string - the extension check says nothing about the directory portion, so..\..\secrets\config.pdfstill passes. - Treating
File.Exists()as a security check - confirming a file exists at an attacker-influenced path is not the same as confirming the caller is authorized to read it.