Safeguard
Industry Analysis

Path Traversal Prevention in C++ with std::filesystem::we...

Why std::filesystem::weakly_canonical alone doesn't stop path traversal in C++, and the containment check every extraction, upload, or plugin loader needs beside it.

Aman Khan
AppSec Engineer
7 min read

CWE-22, Path Traversal, has held a top-ten spot on MITRE's CWE Top 25 Most Dangerous Software Weaknesses since the list existed, ranking 8th in the 2023 edition. C++ codebases are not exempt just because the language lacks the string-heavy web frameworks that usually get blamed for this bug class — archive extractors, plugin loaders, game asset pipelines, and self-hosted file servers written in C++ resolve untrusted relative paths against a trusted root directory constantly. Since C++17 shipped std::filesystem in 2017, teams have reached for std::filesystem::weakly_canonical() as the fix, because unlike canonical() it doesn't throw when part of the path doesn't exist yet. That makes it attractive for validating upload destinations and extraction targets before the file is created. But weakly_canonical() normalizes a path — it does not, by itself, prove that path stays inside the directory you meant to allow. This post covers what the function actually does, where teams misuse it, and the containment check that has to sit next to it.

What is path traversal, and why does it still show up in C++ codebases?

Path traversal is an attacker supplying a relative path segment, most often ../, that a program resolves against a trusted directory without checking that the result stays inside it. In C++, the classic entry points are archive extraction (a .zip or .tar entry named ../../etc/passwd), file-upload handlers that build a destination path from a user-supplied filename, and plugin or asset loaders that accept a relative resource path from a config file or network message. The bug class earned the nickname "Zip Slip" after a 2018 disclosure from Snyk's security research team documented the same unchecked-archive-extraction pattern across more than 20 libraries spanning Java, .NET, Go, Node.js, and native code. The underlying defect is identical regardless of language: code concatenates a trusted base directory with an untrusted relative path and opens the result without verifying containment. C++ is more exposed than it looks because so much archive, installer, and update-mechanism code — the exact surfaces attackers target for supply chain persistence — is still written in C++ for performance and platform-API access.

What does std::filesystem::weakly_canonical actually do?

weakly_canonical() resolves the longest leading portion of a path that exists on disk through the same OS-level resolution as canonical() — following symlinks and eliminating . and .. — and then lexically normalizes whatever trailing portion doesn't exist yet, without touching the filesystem for that part. This is the specific problem it was added to solve: std::filesystem::canonical(), standardized alongside it in C++17, throws filesystem_error (or sets an error_code) if any component of the path doesn't exist, which makes it useless for validating a destination file you're about to create — the whole point of checking a path before an extraction or upload writes to it. weakly_canonical() sidesteps that by only requiring the existing prefix to be real. For example, if /data/uploads exists and a caller passes /data/uploads/../../etc/passwd, weakly_canonical() returns /etc/passwd — it correctly resolves the traversal. That correctness is exactly what causes the false sense of security: the function did its job, normalizing the path, but nothing in that call rejected the result.

Why doesn't calling weakly_canonical alone stop the attack?

It doesn't stop the attack because normalizing a path and validating a path are two different operations, and weakly_canonical() only does the first one. A depressingly common pattern looks like this:

fs::path target = fs::weakly_canonical(base_dir / user_filename);
std::ofstream out(target); // still writes to /etc/passwd

The call resolves cleanly, returns a well-formed absolute path, and the code proceeds to open it — because nothing checked whether target is actually inside base_dir. A second, more subtle failure shows up even in code that does add a containment check, when that check is a naive string comparison: target.string().starts_with(base_dir.string()). That passes for /var/data-secret when base_dir is /var/data, because "/var/data-secret" starts with "/var/data" as a substring even though it's a sibling directory, not a child. This exact prefix-boundary mistake has recurred across static file servers in multiple languages for over a decade, and it survives code review because the diff looks like a legitimate security check.

What does a correct weakly_canonical-based guard look like?

A correct guard canonicalizes both the trusted root and the candidate path, then checks containment using path-component comparison rather than string comparison. std::filesystem::path::lexically_relative() does this cleanly: it returns a relative path that begins with .. the moment the candidate escapes the root, and comparing path iterators (rather than raw strings) sidesteps the /var/data vs. /var/data-secret bug entirely, because path comparison operates on whole components.

namespace fs = std::filesystem;

fs::path resolve_within_root(const fs::path& root,
                              const std::string& user_supplied) {
    fs::path root_canon = fs::weakly_canonical(root);
    fs::path candidate  = fs::weakly_canonical(root_canon / user_supplied);

    fs::path rel = candidate.lexically_relative(root_canon);
    if (rel.empty() || rel.native().substr(0, 2) == fs::path("..").native()) {
        throw std::runtime_error("path traversal rejected: " + user_supplied);
    }
    return candidate;
}

Two operational details matter here. First, canonicalize root too, not just the candidate — if root itself contains an unresolved symlink, comparing an unresolved root against a resolved candidate produces false containment failures or, worse, false passes. Second, run this check immediately before the file operation, not earlier in request handling — a TOCTOU (time-of-check to time-of-use) window opens if a symlink is swapped in between validation and the actual open()/ofstream call, which is a real concern on shared filesystems and matters more once you're resolving through canonical()'s symlink following. For high-assurance extraction code, opening with O_NOFOLLOW-equivalent flags after the check closes that window further.

Where has this exact gap caused real incidents?

This exact gap — resolve without contain-check — has caused incidents across every language ecosystem that handles archive extraction, which is why it's worth C++ teams treating it as a known-recurring defect class rather than a one-off bug. The clearest illustration is CVE-2007-4559, a path traversal in Python's tarfile module that let a crafted .tar archive write files anywhere the extracting process could write. It sat unfixed for over fifteen years — a 2022 audit by Trellix found the vulnerable pattern copy-pasted into more than 300,000 code repositories — before Python core added an extraction filter parameter in late 2023, shipped as the default in Python 3.14, that performs exactly the containment check described above. Separately, CVE-2018-20685 showed the same failure mode in the C implementation of the OpenSSH scp client: it failed to properly validate filenames returned by the remote server, letting a malicious or compromised SCP server write to unintended local paths. Neither of these is C++ specifically, but both are compiled, systems-level code performing the identical operation C++ archive and update tooling performs daily — join untrusted name to trusted directory, then write — and both were fixed with the same containment check weakly_canonical() plus lexically_relative() gives you for free in modern C++.

How Safeguard Helps

Safeguard's static analysis pipeline flags exactly this pattern in C++ source during SCA and SAST scans: calls to weakly_canonical(), canonical(), or raw path concatenation that feed into file-open, extraction, or write operations without a preceding containment check are surfaced as findings tied to CWE-22, with the specific call site and data-flow path from the untrusted input to the sink. Because Safeguard scans dependencies as well as first-party code, it catches this defect class inside vendored archive libraries and third-party plugin loaders pulled into your build — the same components that turned Zip Slip and tarfile-style bugs into supply chain incidents rather than isolated app bugs. Findings come with the exact remediation pattern shown above, mapped to your build's C++ standard version, so engineering teams get a fix that compiles against their actual toolchain rather than a generic advisory. For teams under SOC 2 or similar attestation requirements, Safeguard tracks these findings through remediation with an audit trail, turning "we reviewed our path-handling code" from a claim into evidence.

Never miss an update

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