Safeguard
Industry Analysis

Path Traversal Prevention in C# with Path.GetFullPath

Path.GetFullPath resolves traversal sequences, but it isn't a sanitizer on its own. Here's how C# teams get containment checks wrong, and how to fix them.

Aman Khan
AppSec Engineer
7 min read

On March 30, 2021, Microsoft patched CVE-2021-26701, a .NET Core remote code execution vulnerability rooted in how the runtime handled untrusted path input. It was a reminder that even mature, memory-safe frameworks can ship path traversal bugs when developers assume string concatenation is "safe enough." In C#, the fix for most of these bugs starts with a single, misunderstood method: Path.GetFullPath. Used correctly, it collapses .. segments, resolves relative paths against a known base, and lets you verify the result stays inside an allowed directory. Used carelessly — or skipped entirely — it becomes the reason attackers can read web.config, overwrite appsettings.json, or pull /etc/passwd off a Linux container through an upload endpoint. This post breaks down how Path.GetFullPath actually works, where teams misuse it, and how to build a traversal-proof file access layer in under 20 lines of code.

What Does Path.GetFullPath Actually Do?

Path.GetFullPath converts a relative or partially-qualified path into an absolute, canonical path by resolving . and .. segments against the current working directory (or a base path you supply as a second argument since .NET Core 2.1). For example, Path.GetFullPath("../../etc/passwd") run from C:\app\uploads\temp returns C:\app\etc\passwd — the traversal segments are collapsed into a real, absolute location rather than being rejected. That distinction matters: GetFullPath is a normalization function, not a security control. It will happily hand you the resolved path to a file outside your intended directory; it just won't leave the .. characters sitting in the string anymore. Since .NET Core 2.1 (released May 2018), the two-argument overload Path.GetFullPath(string path, string basePath) lets you resolve relative paths against an explicit base rather than Environment.CurrentDirectory, which is the version almost every secure implementation should use, since CurrentDirectory can change at runtime or differ between IIS, Kestrel, and console hosting.

Why Do Developers Get This Wrong So Often?

Because GetFullPath looks like a sanitizer and isn't one, and the missing second step — verifying the result — is easy to forget under deadline pressure. A common but broken pattern looks like this:

string filePath = Path.Combine(baseDirectory, userInput);
string fullPath = Path.GetFullPath(filePath);
return File.ReadAllBytes(fullPath); // no containment check

This compiles, passes code review at a glance, and works fine for userInput = "report.pdf". It fails catastrophically for userInput = "..\\..\\..\\Windows\\System32\\drivers\\etc\\hosts", because Path.Combine does not validate anything — per Microsoft's own documentation, if the second argument is rooted or contains traversal sequences, Combine just concatenates it, and GetFullPath then resolves it to wherever it points. A 2023 review of public GitHub repositories by security researchers found this exact Combine + GetFullPath pattern, minus a containment check, in hundreds of ASP.NET Core file-handling controllers — usually in upload, export, or "download attachment by filename" endpoints where the filename comes straight from a query string.

What Does a Correct Implementation Look Like?

A correct implementation resolves the path, then explicitly confirms the resolved path is still inside the allowed root before touching the filesystem. The minimum viable pattern is:

public static string SafeCombine(string baseDirectory, string userInput)
{
    string basePath = Path.GetFullPath(baseDirectory);
    string fullPath = Path.GetFullPath(Path.Combine(basePath, userInput));

    if (!fullPath.StartsWith(basePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
    {
        throw new UnauthorizedAccessException("Path traversal attempt detected.");
    }

    return fullPath;
}

Three details make or break this check. First, always append Path.DirectorySeparatorChar before comparing, otherwise C:\app\uploads2 will incorrectly pass a StartsWith("C:\\app\\uploads") check against a sibling directory. Second, normalize basePath through GetFullPath too — comparing a raw baseDirectory string against a resolved fullPath is a mismatch waiting to happen if the base itself contains a symlink or relative segment. Third, use StringComparison.OrdinalIgnoreCase on Windows, since NTFS is case-insensitive by default and a case-sensitive comparison can be bypassed by changing the casing of the traversal payload. On Linux/macOS deployments, drop the IgnoreCase since those filesystems are typically case-sensitive.

Does GetFullPath Stop Every Traversal Technique?

No — encoded and OS-specific payloads still need handling before the string ever reaches GetFullPath. URL-encoded traversal sequences like %2e%2e%2f or double-encoded %252e%252e%252f arrive at your controller already decoded by the ASP.NET Core routing middleware in most cases, but if you're reading raw query strings or headers manually, decode fully before validating. Null byte injection (file.txt%00.jpg) was historically used to truncate paths in native code, and while .NET's managed string handling generally throws a System.ArgumentException for null characters in paths — you can verify this by checking path.IndexOf('\0') — legacy interop layers calling into native file APIs can still be vulnerable. Windows also introduces device names (CON, NUL, AUX, COM1COM9, LPT1LPT9) and alternate data streams (file.txt:hidden.exe) as edge cases that a pure ..-collapsing check won't catch, so pair GetFullPath containment checks with an allowlist of expected file extensions and, where possible, a Path.GetInvalidPathChars() check up front.

What Real-World Incidents Show This Pattern Failing?

Multiple high-profile incidents trace back to exactly this gap between path resolution and path validation. CVE-2021-26701 involved .NET Core mishandling of URL decoding that allowed remote code execution through crafted input, prompting the March 2021 patch across .NET Core 2.1, 3.1, and 5.0. Separately, CVE-2018-8356 was an ASP.NET Core denial-of-service issue tied to improper input handling in the framework's request pipeline. Outside the .NET ecosystem, the pattern repeats constantly enough that OWASP lists path traversal under A01:2021 - Broken Access Control, the single largest category in the 2021 OWASP Top 10, covering 3.81% of tested applications with at least one related CVE according to OWASP's own data set for that release. In every case, the root cause is the same two-line gap: resolve the path, forget to check where it landed. For supply chain risk specifically, this matters even more because the vulnerable code often isn't yours — it's a transitive dependency's file-handling utility that your build pulls in without review.

How Do You Test That Your Fix Actually Works?

You test it by throwing the exact payloads attackers use at your endpoint and confirming the containment check fires, not just by reading the code and assuming it's fine. A minimal test matrix should include: ../../../etc/passwd, ..\\..\\..\\Windows\\win.ini, URL-encoded variants (%2e%2e%2f), double-encoded variants (%252e%252e%252f), absolute paths (C:\Windows\System32\config\SAM or /etc/shadow), and UNC paths (\\attacker-server\share) if your application runs on Windows and accepts network paths. Automated tools help here — static analysis rules for CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) can flag the unsafe Path.Combine + GetFullPath pattern before it ships, and dependency scanning catches known-vulnerable NuGet packages before they're compiled into a production build. Manual code review alone catches this maybe half the time, because the vulnerable line looks nearly identical to the safe one — the difference is a single missing if statement.

How Safeguard Helps

Safeguard is built to catch exactly this class of gap before it ships and before it hides inside a dependency you didn't write. Our software composition analysis flags NuGet packages with known CWE-22 path traversal advisories the moment they enter your build, cross-referenced against CVE data so you're not relying on manual changelog reading to catch a patch like CVE-2021-26701. Our SAST rules for .NET specifically target the unsafe Path.CombinePath.GetFullPath pattern without a containment check, flagging it in pull requests before merge rather than in a post-incident review. Because path traversal is a build-time and supply-chain concern as much as a runtime one, Safeguard also tracks provenance across your CI/CD pipeline — so if a compromised or typosquatted package introduces file-handling code with this exact flaw, you see it attributed to the artifact that introduced it, not buried in a stack trace weeks later. Teams shipping ASP.NET Core file upload, export, or attachment-download features use Safeguard to turn "we think our path validation is correct" into a verified, continuously-monitored guarantee.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.