Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix path traversal in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Use Path.GetFullPath(name, baseDir) and serve the result only if it starts with the base plus a trailing separator, compared with StringComparison.Ordinal. GetFullPath collapses ../ but never reads the disk, so also refuse a symlink anywhere between base and file (FileInfo.LinkTarget). Path.Combine discards the base for a rooted name; don't strip "../".

Stack
  • Language C# 14
  • Runtime .NET 10.0.12 (SDK 10.0.112)
  • Framework ASP.NET Core 10 minimal API
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 25 KB
On this page

Steps

  1. Find every File.*, FileStream, PhysicalFileResult or PhysicalFile call whose path includes request data (route values, query strings, form fields or IFormFile.FileName), including in services the endpoint calls.
  2. Build the base prefix as Path.TrimEndingDirectorySeparator(Path.GetFullPath(baseDir)) plus Path.DirectorySeparatorChar, then turn the request into Path.GetFullPath(name, prefix).
  3. Serve the file only if the full path starts with the prefix (StringComparison.Ordinal) and no component between the base and the file is a symlink; answer the same 404 for refused and missing paths.
  4. Reject an empty path and a NUL byte with 400 before touching the file system: System.IO throws ArgumentException ("Null character in path.") on a NUL byte.
  5. Add regression tests with this card's payloads (WebApplicationFactory against the real store): ../, %2e%2e%2f, a rooted path, ..\, the files-secret sibling, a symlink created with Directory.CreateSymbolicLink, and ....//.
  6. When users only pick from known files, map an ID to a server-side path instead of accepting a path at all.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerablesrc/Files/impl/VulnerableFileStore.csC#
public byte[]? Read(string requestedPath) =>
    File.ReadAllBytes(Path.Combine(_baseDir, requestedPath));

With IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).

The fix

Fixedsrc/Files/impl/FixedFileStore.csC#
public byte[]? Read(string requestedPath)
{
    // End the base with a separator: /srv/files-secret starts with "/srv/files" but is not inside it.
    var baseDir = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_baseDir)) + Path.DirectorySeparatorChar;
    // GetFullPath makes the path absolute and collapses ../; a rooted requestedPath replaces the base.
    var fullPath = Path.GetFullPath(requestedPath, baseDir);
    if (!fullPath.StartsWith(baseDir, StringComparison.Ordinal))
    {
        return null;
    }
    // GetFullPath never looks at the disk, so a symlink inside the base could still lead out of it.
    // Refuse a link anywhere between the base directory and the file.
    for (var path = fullPath; path.Length > baseDir.Length; path = Path.GetDirectoryName(path)!)
    {
        if (new FileInfo(path).LinkTarget is not null)
        {
            return null;
        }
    }
    return File.ReadAllBytes(fullPath);
}

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Files/impl/WrongFileStore.csC#
public byte[]? Read(string requestedPath)
{
    var cleaned = requestedPath.Replace("../", "");
    return File.ReadAllBytes(Path.Combine(_baseDir, cleaned));
}

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Files.Tests/ExploitTests.csC#
/// <summary>Exploit: each payload aims at a real file outside files/, so a leak would come back as a 200.</summary>
public sealed class ExploitTests(FilesApiFactory factory) : FilesApiTests(factory)
{
    [Fact]
    public Task DotDotSlashCannotClimbOutOfTheBaseDirectory() =>
        AssertNotServed(Get("../secret.txt"));

    [Fact]
    public Task PercentEncodedDotDotSlashIsDecodedAndStillRefused() =>
        AssertNotServed(GetRaw("path=%2e%2e%2fsecret.txt"));

    [Fact]
    public Task RootedPathCannotReplaceTheBaseDirectory() =>
        AssertNotServed(Get(Path.Combine(Factory.TempDir, "secret.txt")));

    [Fact]
    public Task BackslashesCannotClimbOutASeparatorOnWindows() =>
        AssertNotServed(Get("..\\secret.txt"));

    [Fact]
    public Task SiblingFolderThatSharesThePrefixIsOutside() =>
        // files-secret starts with "files" but is not inside files/.
        AssertNotServed(Get("../files-secret/api-keys.txt"));

    [Fact]
    public Task SymlinkInsideTheBaseCannotLeadOutsideIt()
    {
        Directory.CreateSymbolicLink(Path.Combine(Factory.BaseDir, "shared"), Path.Combine(Factory.TempDir, "files-secret"));
        return AssertNotServed(Get("shared/api-keys.txt"));
    }

    [Fact]
    public Task FourDotsDoNotTurnBackIntoDotDotSlash() =>
        // A single-pass filter that deletes "../" turns "....//" into "../".
        AssertNotServed(Get("....//secret.txt"));
}

Pitfalls

  • Path.Combine(base, name) returns name when it is rooted and leaves ../ in place; Path.Join keeps the base but does not collapse ../ either. Neither is a check.
  • StartsWith(base) without a trailing separator accepts /srv/files-secret. Also pass StringComparison.Ordinal: StartsWith(string) on its own compares with the current culture (CA1310).
  • GetFullPath only rewrites the string. FileInfo.LinkTarget and ResolveLinkTarget look at the link at one path: for shared/api-keys.txt under a symlinked folder both returned null, so the fix checks every component between the base and the file.
  • This fix refuses every symlink below the base, including one that stays inside it. If you need such links, resolve each component with ResolveLinkTarget and check containment again.
  • PhysicalFileProvider, which the static files documentation uses to serve a folder through UseStaticFiles, refused ../ and rooted paths but served files through a symlinked folder and a symlinked file inside its root that point outside it (.NET 10.0.12).
  • string.Replace("../", "") makes one pass, so ....// turns back into ../.
  • The query string arrives decoded: a value of %2e%2e%2f binds to the handler parameter as ../.
  • On Linux and macOS, File.ReadAllBytes on a directory throws UnauthorizedAccessException; the endpoint answers 404 for it, as for a missing file.
  • Checking and then reading leaves a window in which someone who can write inside the base can swap in a symlink. Keep the served tree read-only for untrusted users.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs path-traversal/csharp-aspnetcore does this for you).
  2. Against your own endpoint, request ?path=..%2Fsecret.txt, a rooted path and a file reached through a symlink that points outside the base; each must answer 404 without the file's content.
  3. Run semgrep --config r/csharp.lang.security.filesystem.unsafe-path-combine.unsafe-path-combine src/; expect one finding, in VulnerableFileStore. It misses WrongFileStore, and CA3003 ignores minimal API inputs, so keep the regression tests as the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix path traversal in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepcsharp.lang.security.filesystem.unsafe-path-combine.unsafe-path-combineFlagged VulnerableFileStore.Read. No finding in WrongFileStore: the rule's source is a method parameter passed straight into Path.Combine, and the wrong code passes requestedPath.Replace(...) instead. No finding in FixedFileStore, which does not call Path.Combine. The fix the rule suggests, Path.GetFileName, only suits a flat folder.Checked: Semgrep 1.180.0 (CE)
CodeQLcs/path-injectionFrom TaintedPathQuery.qll: the path argument of System.IO.File methods is a sink, and parameters of the delegate passed to MapGet are remote sources (flowsources/Remote.qll). A condition on the path that guards the call counts as a sanitiser unless it is a weak guard, and StartsWith is on that list, so the fixed code's prefix check is not recognised and FixedFileStore would probably be reported along with the other two.Checked: github/codeql main at b5b8165; not executed
.NET code analysisCA3003Review code for file path injection vulnerabilitiesOff by default in .NET 10. Enabled, it reported nothing on this card: it did not treat the minimal API handler's parameter as untrusted, nor HttpRequest.Query read inside a minimal API lambda (checked on a probe). In a probe where an MVC controller action passed its parameter to the same three stores, it flagged all three, FixedFileStore included (File.ReadAllBytes and the FileInfo used for the link check), so expect to justify a suppression there.Checked: .NET SDK 10.0.107 with -p:AnalysisModeSecurity=All

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112)
FrameworkASP.NET Core 10 minimal API
System.IO (built in).NET 10.0.12
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform v2, 2.4.0)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Files.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Files.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Files.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/Files.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs path-traversal/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 25 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • dotnetRuntime 10.0.12
  • mvcTesting 10.0.12
  • xunitV3 4.0.1
Matrix
fixed: 12 of 12 tests pass; vulnerable: 5 of 12 tests fail (5 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
IMPL=fixed 12/12 pass; IMPL=vulnerable 5/12 fail, all exploit tests (../, %2e%2e%2f, the rooted path, the files-secret sibling, the symlink); IMPL=wrong 3/12 fail, all exploit tests (the rooted path, the symlink, ....//). The ..\ test passes in every mode on macOS and Linux, where a backslash is an ordinary file-name character; it guards a Windows deployment.
Dependency freshness
Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) is the current 10.0.x patch and over 14 days old. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09), which is a bug-fix release, not a security release. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
Scanners
Semgrep 1.180.0 from a throwaway venv (pip install semgrep==1.180.0), not a card dependency; CA3003 from the .NET 10 SDK; CodeQL read from source, not executed.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP: Path Traversal
  2. OWASP WSTG: Testing Directory Traversal File Include
  3. .NET API: Path.GetFullPath
  4. .NET API: Path.Combine
  5. .NET API: Path.Join (does not discard earlier segments)
  6. .NET API: FileSystemInfo.LinkTarget
  7. .NET API: PhysicalFileProvider
  8. ASP.NET Core: Static files
  9. CA3003: Review code for file path injection vulnerabilities
  10. CA1310: Specify StringComparison for correctness
  11. ASP.NET Core: Integration tests

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.