Skip to content

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 on Path.DirectorySeparatorChar and Path.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 \0 and /. Without the separator check, ..\..\etc\cron.d\job.pdf would 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 anything GetFileName() still parses as a path on the running platform, such as the drive-relative C:report.pdf on 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 a PhysicalFileProvider, 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: nosniff helps prevent browsers from MIME-sniffing responses into executable types.
  • UseStaticFiles serves files but never lists directory contents. Directory browsing is opt-in through UseDirectoryBrowser() or UseFileServer(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 original input (not the sanitized basename) to the file operation. On Windows, ..\..\windows\system32\config passes a "no traversal in the filename" check because the check ran against "config", while the untouched input still resolves through ..\..\ when it reaches File.ReadAllBytes(); on Linux the same happens with ../../etc/passwd. Which separators GetFileName() 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.pdf still 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.

Additional Resources