Skip to main content

Command Palette

Search for a command to run...

Preventing Path Traversal in ASP.NET Core File Download APIs

Updated
10 min readView as Markdown
Preventing Path Traversal in ASP.NET Core File Download APIs

Every API that serves a file eventually grows an endpoint that takes a filename from the client. It ships as a two-line convenience, it works, and it becomes the most dangerous route in the service. Path traversal in ASP.NET Core is not an exotic attack: it is one HTTP request with ../ in it, and on a misconfigured container it reads your appsettings, your data protection keys, or /proc/self/environ with every secret your pod was started with.

I've found this exact pattern in code review more often than any other file-handling bug, usually written by a strong developer in a hurry. The fix is small. The reason it keeps recurring is that the obvious-looking fix is wrong, and .NET has one specific behaviour that turns a "safe" implementation into an unrestricted file read. This article covers the threat, the vulnerable pattern, the implementation that actually holds, and the layers you put around it. The complete hardened file endpoint - resolution, authorisation, streaming and headers - is on Patreon with the tests that prove each control works.

One thing to say early: the strongest control here is not string validation at all, it is never letting the caller name a path in the first place and instead authorising them against a specific resource. That resource-based model is what Chapter 8 of the Zero to Production course builds, using IAuthorizationService.AuthorizeAsync and custom requirements inside a working API, and it is the layer that makes a traversal bug survivable even if one slips through.

ASP.NET Core Web API: Zero to Production

Verified against .NET 10.

The Threat

Path traversal, catalogued as CWE-22, is what happens when attacker-controlled input becomes part of a filesystem path without being constrained to an intended directory. The classic payload walks up out of the storage folder:

GET /files/..%2F..%2F..%2Fetc%2Fpasswd
GET /files/..%5C..%5Cappsettings.Production.json

ASP.NET Core decodes those percent-encoded separators before your handler sees them, so by the time the value reaches your code it is a plain ../../../etc/passwd.

Why It Matters More Than It Used To

Containerised .NET APIs made this worse, not better. The interesting targets are no longer deep in the OS, they sit right next to the binary:

  • appsettings.Production.json in the app directory, frequently holding connection strings.

  • /proc/self/environ, which exposes every environment variable in the process - including the secrets your orchestrator injected.

  • The Data Protection key ring, if it is persisted to a file path. Read those and an attacker can forge authentication cookies and antiforgery tokens.

  • Any .pfx or private key mounted into the container.

Read access alone is enough for a full compromise here. The blast radius is not "someone downloaded a file."

The Vulnerable Pattern

The endpoint that keeps getting written:

app.MapGet("/files/{name}", (string name) =>
    Results.File(Path.Combine(StorageRoot, name), "application/octet-stream"));

Path.Combine looks like it constrains the result to StorageRoot. It does not. It is a string join with separator handling, and it has one behaviour that is critical to understand:

If any later argument is an absolute path, Path.Combine discards everything before it and returns that absolute path.

So Path.Combine("/srv/app/files", "/etc/passwd") returns /etc/passwd. Not a subdirectory of storage - the absolute path itself. On Windows, Path.Combine(@"C:\app\files", @"C:\Windows\win.ini") returns C:\Windows\win.ini.

That means an attacker does not even need ../. A leading slash is enough. Any validation that only strips or rejects .. sequences misses this entirely, which is why the naive fix fails.

The Fix That Only Half Works

The common advice is Path.GetFileName:

var safeName = Path.GetFileName(name);   // strips every directory component

This does work for the narrow case of a flat storage folder. Path.GetFileName("../../etc/passwd") returns passwd, and Path.GetFileName("/etc/passwd") returns passwd. If your files genuinely live in one directory with no nesting, this is a legitimate and simple control.

It stops working the moment you need subdirectories - per-tenant folders, date partitioning, anything nested - because it flattens legitimate paths too. Teams then relax it, and the relaxed version is where the vulnerability comes back.

The Implementation That Holds

If the caller must be able to reference a nested path, the only reliable check is to fully resolve the candidate path and then verify it is still inside the root:

static bool TryResolveInsideRoot(string root, string requested, out string fullPath)
{
    var rootFull = Path.GetFullPath(root);
    if (!rootFull.EndsWith(Path.DirectorySeparatorChar))
        rootFull += Path.DirectorySeparatorChar;

    fullPath = Path.GetFullPath(Path.Combine(rootFull, requested));
    return fullPath.StartsWith(rootFull, StringComparison.Ordinal);
}

Three details carry the weight here, and skipping any one of them reintroduces the bug:

  1. Path.GetFullPath normalises first. It collapses .. and . segments and produces a canonical absolute path. Comparing before normalising compares a string an attacker controls.

  2. The trailing separator on the root is not cosmetic. Without it, a sibling directory named /srv/app/files-public passes a StartsWith("/srv/app/files") check. With it, it does not.

  3. The absolute-path case is caught by the comparison, not by Path.Combine. When requested is /etc/passwd, Combine returns /etc/passwd, GetFullPath leaves it alone, and StartsWith correctly returns false. The check is what saves you.

Use StringComparison.Ordinal on Linux. On Windows, where the filesystem is case-insensitive, use OrdinalIgnoreCase - a case-varied prefix would otherwise slip past. If you deploy to both, branch on OperatingSystem.IsWindows() rather than picking one and hoping.

One more gap worth knowing: Path.GetFullPath does not resolve symbolic links. A symlink inside your storage root pointing at /etc passes every check above. If untrusted users can create files in that root, resolve the link target explicitly with File.ResolveLinkTarget (available since .NET 6) and re-check the result.

The Control That Makes All of This Unnecessary

Everything above is defending a design decision you did not have to make. The stronger pattern is to never accept a path at all:

app.MapGet("/files/{id:guid}", async (
    Guid id, IFileCatalog catalog, IAuthorizationService auth,
    ClaimsPrincipal user, CancellationToken ct) =>
{
    var record = await catalog.FindAsync(id, ct);
    if (record is null) return Results.NotFound();

    var result = await auth.AuthorizeAsync(user, record, "CanDownloadFile");
    if (!result.Succeeded) return Results.NotFound();

    return Results.File(record.PhysicalPath, record.ContentType, record.DownloadName);
});

The client sends an opaque identifier. The physical path comes from your own catalogue, never from the request. Traversal is structurally impossible because there is no attacker-controlled string anywhere near the filesystem.

Note the NotFound on an authorisation failure rather than Forbid. Returning 403 for files that exist and 404 for files that do not turns the endpoint into an existence oracle - that is Broken Object Level Authorization, and we cover it properly in preventing BOLA in ASP.NET Core APIs.

Adopt this design for new endpoints. Retrofit it where you can. Where you genuinely cannot, use the resolution check above and treat it as a compensating control rather than the primary one.

What About Uploads?

Everything here applies in reverse and is worse, because a traversal on write means arbitrary file creation. An upload endpoint that trusts IFormFile.FileName and combines it with a storage root can drop a file anywhere the process can write.

IFormFile.FileName is a client-supplied header value. Never use it as a path component. Generate your own storage name - a GUID is fine - and keep the original name as metadata for the Content-Disposition header only. Our file upload decision guide covers the surrounding design.

Defence-in-Depth Checklist

No single control should be load-bearing. Layer these:

  • [ ] Prefer opaque identifiers over caller-supplied paths on every new file endpoint.

  • [ ] Resolve and verify containment with Path.GetFullPath plus a trailing-separator prefix check where a path is unavoidable.

  • [ ] Authorise the resource, not just the route. [Authorize] proves who is calling; it says nothing about whether they own this file.

  • [ ] Return 404, not 403, for unauthorised resources so the endpoint does not confirm existence.

  • [ ] Run the process as non-root with a read-only root filesystem. Then even a successful traversal reads very little.

  • [ ] Keep secrets out of the filesystem. Environment variables and a secrets manager beat a config file the API process can read.

  • [ ] Sanitise the download filename before it reaches Content-Disposition. A filename containing CR or LF is a header injection, a separate bug with the same root cause.

  • [ ] Log every rejected path with the raw requested value. Traversal attempts are reconnaissance and are worth alerting on.

  • [ ] Test the attack, not just the happy path. An integration test asserting that ../../appsettings.json returns 404 stops this from regressing.

That last item is what keeps the fix alive. Controls without tests get refactored away.

Frequently Asked Questions

Does Path.Combine Prevent Path Traversal in ASP.NET Core?

No. Path.Combine is string concatenation with separator handling and performs no containment check. Worse, if a later argument is an absolute path it discards the earlier ones and returns that absolute path, so an attacker only needs a leading slash rather than a ../ sequence. Always follow it with Path.GetFullPath and an explicit prefix comparison against the canonical root.

Is Path.GetFileName Enough to Stop Path Traversal?

It is sufficient only when files live in a single flat directory, because it strips every directory component. It is not sufficient when your storage is nested, since it flattens legitimate paths too and teams then relax it. For nested storage, resolve the full path and verify containment instead.

How Do I Test for Path Traversal in My Own API?

Write integration tests that request ../../appsettings.json, the percent-encoded ..%2F..%2Fappsettings.json, an absolute path such as /etc/passwd or C:\Windows\win.ini, and a sibling-directory prefix like ../files-public/secret.txt. Each must return 404. Run them in CI so a future refactor cannot silently remove the check.

Does ASP.NET Core Block Traversal Automatically Anywhere?

Partly, and only for static files. UseStaticFiles serves through a PhysicalFileProvider rooted at a specific directory and will not serve outside it. That protection applies to the static file middleware, not to your own endpoints - a custom handler calling Results.File with a caller-supplied path has no such guard.

Can Path Traversal Lead to More Than Reading Files?

Yes. On an upload endpoint it becomes arbitrary file write, which can mean overwriting configuration or dropping a file into a directory that gets executed. On read, exposure of a Data Protection key ring or a private key escalates to authentication bypass. Treat it as a critical finding, not an information leak.

Where Is the Authoritative Reference for This Class of Vulnerability?

The OWASP Path Traversal reference documents the attack variants, encodings and platform-specific cases, and is the standard to test your endpoints against. Pair it with our ASP.NET Core API security checklist for the surrounding controls.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

322 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.