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. input
reports/q2.pdf - 2. full path
/srv/downloads/reports/q2.pdf - 3. relative to root
reports/q2.pdf
Traversal request
denied- 1. input
reports/../../appsettings.json - 2. full path
/srv/appsettings.json - 3. relative to root
../appsettings.json
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.