Skip to content
Katabench
Try free
8 min read The Katabench team

Path Traversal Vulnerability in ASP.NET Core

A path traversal vulnerability lets user input escape a trusted directory. See the unsafe C# patterns, the containment check, and tests that prove it.

The endpoint looks harmless. A customer clicks an invoice, the browser asks for /downloads?name=invoice-1042.pdf, and ASP.NET Core returns a file from the downloads directory. The code is four lines long, passes its functional tests, and has probably survived several code reviews.

app.MapGet("/downloads", (string name, IWebHostEnvironment env) =>
{
    var root = Path.Combine(env.ContentRootPath, "Downloads");
    var path = Path.Combine(root, name);
    return Results.File(path, "application/pdf");
});

Now request ../../appsettings.Production.json. Path.Combine builds a path, but it does not enforce a boundary. The filesystem resolves the .. segments, walks above Downloads, and opens a configuration file the endpoint was never meant to expose. If the process can read the file, the attacker can ask for it.

That is a path traversal vulnerability, also called directory traversal or file path injection. The CWE-22 definition is precise: external input controls a pathname intended to stay inside a restricted directory, but the software does not neutralize the path elements that escape it. In a .NET application, the bug is not that strings may contain two dots. The bug is that code turns untrusted input into filesystem authority without proving where the final path lands.

The attack survives obvious fixes

The first patch usually searches for .. and rejects or removes it:

if (name.Contains("..", StringComparison.Ordinal))
    return Results.BadRequest();

That checks one spelling of one traversal technique. It does not define which files are allowed. Different operating systems recognize different separators and rooted path forms. A later layer may decode an encoded separator before the filesystem sees it. A rooted second argument can cause path combination to discard the directory you thought was trusted. Meanwhile, a legitimate filename such as release..notes.pdf gets rejected even though it never leaves the directory.

Repeated decoding is especially treacherous. ASP.NET Core has already parsed route and query values before your endpoint receives them. If one layer decodes input again, text that looked inert to an earlier check can become a separator or .. segment afterward. The right rule is not "strip the characters attackers use." It is "interpret the path once, then prove the interpreted result stays inside the directory."

The same distinction appears in Microsoft's CA3003 analyzer guidance: prefer a known-safe list when possible, otherwise validate untrusted file input before it reaches a file operation. Static analysis can point at the data flow. Your boundary check still has to make the security decision.

Normalize, then prove containment

trusted root: /srv/downloads

The final relative path decides

Safe request

inside root
  1. 1. input reports/q2.pdf
  2. 2. full path /srv/downloads/reports/q2.pdf
  3. 3. relative to root reports/q2.pdf

Traversal request

denied
  1. 1. input reports/../../appsettings.json
  2. 2. full path /srv/appsettings.json
  3. 3. relative to root ../appsettings.json
Looking for the text ".." guesses at an attack. Resolving the path and measuring it from the trusted root proves whether it escaped.

A path is safe because its resolved destination is inside a directory you trust, not because its original spelling omitted a substring you distrust.

The strongest fix is to stop accepting paths

Most download endpoints do not need a filename from the caller. They need an identifier. If the set of downloadable files is known, map public IDs to server-owned paths and let user input choose only a key:

private static readonly IReadOnlyDictionary<string, string> Exports =
    new Dictionary<string, string>(StringComparer.Ordinal)
    {
        ["invoice-1042"] = "invoice-1042.pdf",
        ["tax-summary-2025"] = "tax-summary-2025.pdf",
    };

app.MapGet("/downloads/{id}", (string id, IWebHostEnvironment env) =>
{
    if (!Exports.TryGetValue(id, out var trustedName))
        return Results.NotFound();

    var path = Path.Combine(env.ContentRootPath, "Downloads", trustedName);
    return Results.File(path, "application/pdf", trustedName);
});

The request value never becomes a path. It can contain slashes, dots, a drive letter, or a poem about /etc/passwd; none of that reaches Path.Combine. This is the file equivalent of allowlisting a sort column in a SQL query. Choose a trusted output from a finite map instead of trying to sanitize an unbounded input language.

In a real system the mapping will usually live in a database, and authorization belongs in the same lookup. Query the export by both its public ID and the current user or tenant. Otherwise you may close the traversal hole while leaving an insecure direct object reference that lets one customer download another customer's valid file.

When callers genuinely choose a relative path

Some applications intentionally expose a directory tree: documentation bundles, generated reports, or assets arranged into subdirectories. In that case, normalize the trusted root and the candidate, then ask .NET for the candidate's path relative to the root. An outside path has to climb upward.

public static bool TryResolveWithinRoot(
    string rootPath,
    string requestedPath,
    out string? resolvedPath)
{
    resolvedPath = null;
    if (string.IsNullOrWhiteSpace(requestedPath))
        return false;

    var root = Path.GetFullPath(rootPath);
    string candidate;

    try
    {
        candidate = Path.GetFullPath(requestedPath, root);
    }
    catch (Exception ex) when (
        ex is ArgumentException or NotSupportedException or PathTooLongException)
    {
        return false;
    }

    var relative = Path.GetRelativePath(root, candidate);
    var climbsAboveRoot =
        relative is "." or ".." ||
        relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) ||
        Path.IsPathRooted(relative);

    if (climbsAboveRoot)
        return false;

    resolvedPath = candidate;
    return true;
}

Path.GetFullPath collapses the path according to the host operating system. Then Path.GetRelativePath describes where the candidate sits from the trusted root. A result beginning with one complete .. segment means the candidate is outside. Checking the complete segment matters: ..archive/report.pdf is a child name, while ../report.pdf climbs to the parent.

This approach avoids the common prefix bug. A check such as candidate.StartsWith("/srv/downloads") accepts /srv/downloads-private/secrets.txt, because text prefixes do not understand directory boundaries. It also invites case-sensitivity mistakes when code runs on a different operating system. Path.GetRelativePath applies the platform's path semantics and makes the boundary visible in the result.

After containment succeeds, apply the feature's own policy. A report download may allow .pdf and .csv, reject directories, enforce a size limit, and return a generic not-found response. Those are separate decisions. An extension allowlist does not replace containment, because ../../private/payroll.pdf has a perfectly acceptable extension and a completely unacceptable destination.

Test the boundary with attacker-shaped inputs

A happy-path test proves only that report.pdf still downloads. The security contract needs tests that approach the root from both sides and run on the same operating-system semantics as production.

[Theory]
[InlineData("report.pdf", true)]
[InlineData("2026/q2/report.pdf", true)]
[InlineData("../secrets.txt", false)]
[InlineData("2026/../../secrets.txt", false)]
[InlineData(".", false)]
public void Resolved_download_never_escapes_the_root(
    string requested,
    bool expected)
{
    var root = Path.Combine(Path.GetTempPath(), "download-boundary-tests");

    var accepted = TryResolveWithinRoot(root, requested, out var resolved);

    Assert.Equal(expected, accepted);
    Assert.Equal(expected, resolved is not null);
}

Add a separate test for a fully qualified path created on the current platform. Include repeated segments, empty input, oversized input, and any decoding behavior your HTTP boundary performs. If the endpoint promises leaf filenames rather than subdirectories, test that both / and \ are rejected by that stricter policy. The point is not to collect famous payload strings. It is to state an invariant and make inputs work hard to falsify it.

This is also where a functional-only suite fails you. The vulnerable implementation and the safe one both return report.pdf. Only the attack cases distinguish them. The same gap keeps SQL injection alive in otherwise modern C# and makes the broader OWASP Top 10 concrete for .NET teams.

Lexical containment is not the whole filesystem

The helper proves a property of path text after normalization. It does not resolve symbolic links or Windows reparse points. If an attacker can create or replace entries inside the trusted tree, a link inside that tree may point outside it after the lexical check passes. Avoid mixing user-writable content with an app-owned download tree. Run the process with access only to the files it needs, and use storage APIs or operating-system facilities that can enforce the boundary when hostile users can mutate directory entries.

There is also a timing boundary. Checking File.Exists and opening by path later leaves a window in which the filesystem can change. Do not treat existence as authorization. Establish authorization and containment, then open the file immediately with the narrowest access you need. Keep secrets and application configuration outside any directory the download process is permitted to read.

These layers matter because traversal is an authorization bug expressed through a pathname. The OWASP attack description focuses on the same consequence: user-controlled file operations reach files outside the web document root. Path normalization closes one route. Least privilege limits the damage if another route survives.

Make the secure response the practiced response

The dangerous implementation is easy to write because it mirrors the feature request: take a name, join it to a folder, return the file. The secure implementation requires a different reflex: reduce authority first, normalize when paths are unavoidable, and prove containment before the file API sees the result.

Katabench's secure-coding track turns that reflex into a graded exercise. The starter download works for normal files and fails only when an adversarial suite submits traversal paths and rooted requests. Your fix has to block the exploit while the functional behavior stays green. The grading model keeps those two obligations separate, so deleting the feature is not a security solution.

That is the useful form of secure-coding practice. You are not memorizing another list of dangerous substrings. You are writing a boundary, watching real counterexamples break the weak version, and repeating the correction until a raw user value next to Path.Combine looks unfinished on sight.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.