Path traversal (directory traversal) in a file download endpoint, why Path.Combine does not protect you, and safe patterns including root checks, ID mapping and Zip Slip.
It is a path traversal vulnerability. file=../../../etc/passwd resolves outside the reports folder, and file=/etc/passwd is worse: Path.Combine discards the base path when the second argument is rooted. The endpoint also has no authorisation and leaks whether files exist.
Fix: do not let users name files. Map an ID to a stored path (database), or if names are required, take only Path.GetFileName(file), build the path, Path.GetFullPath it and check it starts with the root plus a separator, using the platform's case rules. Serve through PhysicalFileProvider or Results.File with the checked path, and add authorisation.
| Problem | Example input | Result |
|---|---|---|
| Relative traversal | ../../../etc/passwd |
Reads /etc/passwd |
| Rooted input | /etc/passwd |
Path.Combine returns /etc/passwd directly |
| Windows variants | ..\..\web.config, C:\secrets.txt, \\attacker\share\x |
Same idea; UNC can trigger outbound SMB auth |
| Encoded input | ..%2f..%2fetc%2fpasswd |
Decoded by model binding before your code runs |
| No authorisation | any | Anyone can download any report |
| Information leak | missing file | 500 or stack trace reveals paths |
Store reports with an ID and owner in the database, and look up the server-side path. The user never controls a path.
Path.GetFileName(file).Path.GetFullPath(Path.Join(root, name))./var/app/reports-old does not pass a /var/app/reports prefix check).A penetration test on an internal portal requested /download?file=..%2F..%2Fappsettings.Production.json and received the production settings, including a database connection string. We rotated secrets that day, replaced the endpoint with ID-based lookup scoped to the user's tenant, and added a Semgrep rule to flag Path.Combine or File.* calls that use request data.
A security-focused code review on a common, high-impact bug (OWASP: broken access control / path traversal).
Green flags
Path.Combine.Red flags
Replace("..", "").root = /var/app/reports
input = ../../../etc/passwd
Path.Combine(root, input) = /var/app/reports/../../../etc/passwd
Path.GetFullPath(...) = /etc/passwd ← outside the root
/
├── etc/passwd ◄─────────┐
└── var/ │ ..
└── app/ ◄───────┤ ..
└── reports/ ────┘ ..
Path.Combine makes it worsePath.Combine("/var/app/reports", "/etc/passwd"); // "/etc/passwd"
Path.Combine(@"C:\reports", @"C:\Windows\win.ini"); // "C:\Windows\win.ini"
The documented behaviour is that a rooted argument resets the result. Path.Join does not do this, but still allows ...
root = /var/app/reports
candidate = /var/app/reports-archive/secret.pdf
candidate.StartsWith(root) → true ✘ wrong!
root + "/" = /var/app/reports/
candidate.StartsWith(root + "/") → false ✔
GetFullPath works on strings. If an attacker can create a symlink inside the root (for example via an upload that preserves links in an archive), the checked path can still point outside. Mitigations: do not extract links from untrusted archives, run with a low-privilege user, and check FileInfo.LinkTarget or resolve with ResolveLinkTarget(true) when links are possible.
The same bug appears when extracting archives: an entry named ../../app/web.config overwrites files outside the target folder.
ZipFile.ExtractToDirectory in modern .NET checks entries and throws IOException if an entry would land outside the destination.entry.ExtractToFile(Path.Combine(dest, entry.FullName)) does not. Apply the same GetFullPath + prefix check per entry.| Layer | Control |
|---|---|
| Design | IDs instead of paths; store files in blob storage with per-object keys |
| Code | GetFileName, GetFullPath, root + separator check |
| Framework | PhysicalFileProvider rejects paths that escape its root |
| Runtime | Run as non-root, read-only file system, mount only the needed folder |
| AuthZ | Check the user owns the resource |
| Detection | WAF rules for ../, logs and alerts on 404 bursts |
/download?file=../../../etc/hosts and /download?file=/etc/hosts.file=..%2F..%2Fetc%2Fhosts and file=reports-old/x to test encoding and the prefix trap.// Best: users ask for an ID; the server owns the path
app.MapGet("/reports/{id:guid}", async (Guid id, ClaimsPrincipal user, ReportsDb db) =>
{
var report = await db.Reports
.Where(r => r.Id == id && r.TenantId == user.GetTenantId())
.Select(r => new { r.StoragePath, r.FileName })
.SingleOrDefaultAsync();
return report is null
? Results.NotFound()
: Results.File(report.StoragePath, "application/pdf", report.FileName);
}).RequireAuthorization();
// If you must accept a name: canonicalise and check containment
public static class SafePath
{
private static readonly StringComparison Comparison =
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
public static bool TryResolve(string root, string userInput, out string fullPath)
{
fullPath = string.Empty;
var name = Path.GetFileName(userInput); // drop any directory parts
if (string.IsNullOrWhiteSpace(name)) return false;
var rootFull = Path.GetFullPath(root);
if (!Path.EndsInDirectorySeparator(rootFull))
rootFull += Path.DirectorySeparatorChar;
var candidate = Path.GetFullPath(Path.Join(rootFull, name));
if (!candidate.StartsWith(rootFull, Comparison)) return false;
fullPath = candidate;
return true;
}
}
app.MapGet("/download", (string file) =>
SafePath.TryResolve("/var/app/reports", file, out var path) && File.Exists(path)
? Results.File(path, "application/octet-stream", Path.GetFileName(path))
: Results.NotFound())
.RequireAuthorization();
// Manual zip extraction with a Zip Slip check
foreach (var entry in archive.Entries)
{
var target = Path.GetFullPath(Path.Join(destRoot, entry.FullName));
if (!target.StartsWith(destRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal))
throw new InvalidDataException($"Blocked entry outside target: {entry.FullName}");
// ... create directory and extract
}
Mistake: Passing user input straight into Path.Combine or any File.* API.
Why it is wrong: Unvalidated input can be relative (..), rooted, encoded or a UNC path, and each form escapes the intended folder in a different way.
Instead: Validate the value first against a strict allow-list, then build and canonicalise the path, and best of all accept an ID rather than a name.
Mistake: Believing Path.Combine keeps the result under the first argument.
Why it is wrong: A rooted second argument replaces the base, and .. segments walk out of it.
Instead: Treat Path.Combine as string joining only and check the normalised result against the root.
Mistake: Blocking traversal with input.Contains("..") or Replace("..", "").
Why it is wrong: Rooted paths, backslashes on Windows and inputs like ....// get past it, and Replace can even create a new ../.
Instead: Allow-list valid names, or canonicalise with Path.GetFullPath and check containment.
Mistake: Checking candidate.StartsWith(root) without a trailing separator.
Why it is wrong: /var/app/reports-archive/x passes a check for /var/app/reports.
Instead: Append Path.DirectorySeparatorChar to the normalised root before comparing.
Mistake: Fixing traversal but forgetting authorisation. Why it is wrong: Users can still read other tenants' reports by guessing names; that is broken access control. Instead: Look files up by ID scoped to the caller's identity and require authorisation on the endpoint.
The strongest fix is design, where users never supply paths. Then traversal is impossible rather than merely blocked.
| Approach | Security | Effort | Notes |
|---|---|---|---|
| Opaque ID mapped to stored path, scoped by tenant | Strongest | Needs a table of files | Also fixes authorisation |
| Blob storage key plus short-lived SAS URL | Strong | Moves files off the web server | No local file system access at all; Azure Blob or S3 |
| Allow-list name validation, then canonicalise and contain | Good | Small helper | Keep it in one shared, tested function |
PhysicalFileProvider rooted at the folder |
Good | Small | Rejects paths that escape its root |
Blocklist checks (.., Contains) |
Weak | Small | Easy to bypass; avoid |
Defence in depth: run the container as a non-root user with a read-only root file system and mount only the folder it needs, keep secrets in a vault rather than files the app can read, and add a static-analysis rule (Semgrep or a Roslyn analyser) that flags request data reaching Path or File APIs. If this bug was ever exposed, I also rotate any secret that could have been read and check access logs for traversal patterns.
\ (and accepts /), Linux and macOS use only /. Never hard-code \; use Path.Combine/Path.Join or Path.DirectorySeparatorChar.C:\data\a.txt or /data/a.txt are absolute; data/a.txt is resolved against the current working directory, which differs between dotnet run, a service and a container. Base paths on AppContext.BaseDirectory or configuration.Path.Combine drops earlier parts if a later part is rooted (Combine("/app", "/etc/passwd") gives /etc/passwd). Path.Join just concatenates with one separator.Config.json vs config.json works on your laptop and fails in the Linux container.rwx bits for owner, group and others (for example 0644, 0755). On a directory, x means "can enter" and w means "can create, delete or rename entries". Root bypasses most checks.The container error usually means the app runs as a non-root user (the official .NET 10 images define user app, UID 1654, via $APP_UID) while the mounted folder is owned by root with 0755. Fix it by giving that UID or a shared group write access (chown, Kubernetes fsGroup, or volume mount options), not by running the container as root or using chmod 777.
How to tell it (STAR)
**S:** A [penetration test] showed our reporting portal would return any file on the server, including production settings. **T:** I led the fix and had to contain the exposure fast. **A:** I rotated the secrets first, added a WAF rule, and shipped a canonicalise-and-contain check that afternoon. Then I replaced file names with tenant-scoped report IDs and found [two] similar bugs with a code search. **R:** The retest passed, secrets were out of files entirely, and a Semgrep rule now blocks the pattern in pull requests.
Last updated 23 Oct 2026.