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 "../".
- 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.
- Build the base prefix as Path.TrimEndingDirectorySeparator(Path.GetFullPath(baseDir)) plus Path.DirectorySeparatorChar, then turn the request into Path.GetFullPath(name, prefix).
- 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.
- 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.
- 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 ....//.
- When users only pick from known files, map an ID to a server-side path instead of accepting a path at all.
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
public byte[]? Read(string requestedPath) =>
File.ReadAllBytes(Path.Combine(_baseDir, requestedPath));With IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).
The fix
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
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 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.
/// <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"));
}- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | csharp.lang.security.filesystem.unsafe-path-combine.unsafe-path-combine | Flagged 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) |
| CodeQL | cs/path-injection | From 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 analysis | CA3003Review code for file path injection vulnerabilities | Off 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 |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| System.IO (built in) | .NET 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.1 (Microsoft Testing Platform v2, 2.4.0) |
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.
dotnet restore tests/Files.Tests --locked-mode# 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.xmlEach 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.
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
- OWASP: Path Traversal
- OWASP WSTG: Testing Directory Traversal File Include
- .NET API: Path.GetFullPath
- .NET API: Path.Combine
- .NET API: Path.Join (does not discard earlier segments)
- .NET API: FileSystemInfo.LinkTarget
- .NET API: PhysicalFileProvider
- ASP.NET Core: Static files
- CA3003: Review code for file path injection vulnerabilities
- CA1310: Specify StringComparison for correctness
- ASP.NET Core: Integration tests
Attack patterns: