📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Code review: this endpoint returns a file from the server based on a name in the query string. What is wrong with it, and how would you fix it? ```csharp app.MapGet("/download", (string file) => { var path = Path.Combine("/var/app/reports", file); return Results.File(path, "application/octet-stream"); }); ```

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.

Senior CodeReview Intermediate aspnet-core code-review file-system owasp path-traversal paths security

🎯 Key points

  • The endpoint has a path traversal (directory traversal) bug: '../../../etc/passwd' climbs out of the reports folder, and URL-encoded forms are decoded by model binding before the code runs.
  • Path.Combine makes it worse: when the user value is rooted ('/etc/passwd', 'C:\\secrets.txt', or a UNC path) the base folder is discarded entirely.
  • Treat the query value as untrusted input: validate it against a strict allow-list (for example ^[A-Za-z0-9_-]{1,100}\.pdf$) before any path operation, and reject anything else with 404.
  • Best fix is design: users pass an opaque ID, the server looks up the stored path or blob key scoped to the caller's tenant, so a user never controls a path.
  • If names are unavoidable: take Path.GetFileName, build with Path.Join, normalise with Path.GetFullPath, and check it starts with the root plus a trailing separator using Ordinal (Linux) or OrdinalIgnoreCase (Windows).
  • Add authorisation (RequireAuthorization plus an ownership check); fixing traversal alone still lets one user download another user's files.
  • String checks do not cover symbolic links, so harden the runtime too: non-root user, read-only root file system, mount only the reports folder, or serve from blob storage with short-lived SAS URLs.
  • Zip Slip is the same bug during archive extraction, and errors should return a plain 404 without echoing resolved paths.

⏱ 30-second answer

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.

🗣 Full answer (senior)

What is wrong

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

Fix 1 (best): do not accept paths at all

Store reports with an ID and owner in the database, and look up the server-side path. The user never controls a path.

Fix 2: if names are required, canonicalise and check containment

  1. Strip directories: Path.GetFileName(file).
  2. Build and normalise: Path.GetFullPath(Path.Join(root, name)).
  3. Check it is still under the root with a trailing separator (so /var/app/reports-old does not pass a /var/app/reports prefix check).
  4. Use ordinal comparison, case-insensitive on Windows.
  5. Return 404 for anything that fails, without details.

Production example

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.

🔍 What the interviewer is checking

A security-focused code review on a common, high-impact bug (OWASP: broken access control / path traversal).

Green flags

  • Spots traversal immediately and knows the rooted-path behaviour of Path.Combine.
  • Proposes ID mapping first, then canonicalise plus containment check.
  • Mentions the trailing-separator trap, authorisation and Zip Slip.
  • Adds runtime hardening (non-root, minimal mounts).

Red flags

  • Fixes with Replace("..", "").
  • Misses authorisation.
  • Thinks ASP.NET Core automatically prevents it for custom file endpoints.

📚 Learn in depth

1. How traversal works

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/  ────┘ ..

2. Why Path.Combine makes it worse

Path.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 ...

3. The prefix check trap

root      = /var/app/reports
candidate = /var/app/reports-archive/secret.pdf
candidate.StartsWith(root) → true   ✘ wrong!

root + "/" = /var/app/reports/
candidate.StartsWith(root + "/") → false  ✔

4. Symbolic links

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.

5. Zip Slip

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.
  • Manual extraction with entry.ExtractToFile(Path.Combine(dest, entry.FullName)) does not. Apply the same GetFullPath + prefix check per entry.

6. Defence in depth

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

7. Try it yourself

  1. Run the vulnerable endpoint locally and call /download?file=../../../etc/hosts and /download?file=/etc/hosts.
  2. Apply the safe version from the code example and confirm both return 404.
  3. Try file=..%2F..%2Fetc%2Fhosts and file=reports-old/x to test encoding and the prefix trap.

💻 Code

// 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
}

⚠ Common mistakes

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate is unsure how Path.Combine or GetFullPath behave Why: Checks the path fundamentals behind the fix
    How to answer
    • Separators: Windows uses \ (and accepts /), Linux and macOS use only /. Never hard-code \; use Path.Combine/Path.Join or Path.DirectorySeparatorChar.
    • Absolute vs relative: 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.
    • Case: Linux file systems are case-sensitive, Windows and default macOS are not. Config.json vs config.json works on your laptop and fails in the Linux container.
  2. When: Candidate suggests running with least privilege Why: Probes file permissions and non-root containers as defence in depth
    How to answer
    • Linux: each file has an owner (UID), a group (GID) and 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.
    • Windows: each file has an ACL (a list of allow and deny entries for users and groups), usually inherited from the parent folder.

    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.

🛠 Real-world problems: how you handle them

Penetration test reads production settings through a download endpoint
Situation
[Internal reporting portal] on ASP.NET Core [10] with a `/download?file=` endpoint used by [~300] staff.
Signals
Penetration test report: request with `..%2F..%2Fappsettings.Production.json` returned the file, including [a database connection string].
Investigation
Reproduced locally, confirmed `Path.Combine` with request input, searched the codebase for other `File.*` calls with request data and found [two] more.
Resolution
Same day: rotated exposed secrets, blocked the pattern at the WAF and deployed the containment check. Within the sprint: replaced name-based download with tenant-scoped ID lookup, moved secrets to [Key Vault].
Prevention
Semgrep rule for request data reaching `Path.Combine`/`File.*`, secrets removed from files on disk, container runs as non-root with a read-only file system.

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.

↩ Usually asked after

More in File System

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me