# 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](https://www.patreon.com/CodingDroplets) 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](https://aspnetcoreapi.codingdroplets.com/) 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](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

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:

```text
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:

```csharp
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`:

```csharp
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:

```csharp
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:

```csharp
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](https://codingdroplets.com/preventing-bola-aspnet-core-api-resource-based-authorization).

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](https://codingdroplets.com/aspnet-core-file-upload-iformfile-vs-streaming-enterprise-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](https://owasp.org/www-community/attacks/Path_Traversal) 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](https://codingdroplets.com/aspnet-core-api-security-checklist-15-production-controls-teams-miss) for the surrounding controls.

* * *

## About the Author

I'm Celin Daniel, Co-founder of [Coding Droplets](https://codingdroplets.com/). 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.

*   GitHub: [codingdroplets](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
