In March 2018, the Rails Sprockets gem shipped a fix for CVE-2018-3760 after researchers showed that a crafted URL like /assets/..%2f..%2f..%2fetc%2fpasswd could pull arbitrary files out of a production server's filesystem and serve them back over HTTP. The bug wasn't exotic — it was a path built from user input, joined onto a base directory, and trusted without verifying where it actually pointed. Ruby applications hit this pattern constantly: file uploads, template renderers, log viewers, asset pipelines, and API endpoints that accept a filename parameter. The fix in most of these cases traced back to one method: File.realpath, which resolves a path to its canonical, symlink-free form and lets you check it against an allowed directory before touching the disk. This post explains what path traversal looks like in Ruby, why the obvious defenses fall short, and how File.realpath closes the gap when used correctly.
What is path traversal and why does it matter for Ruby applications?
Path traversal happens when an attacker manipulates a file path so it resolves outside the directory a developer intended, and in Ruby it almost always starts with unsanitized string concatenation. A typical vulnerable line looks like File.read(File.join(UPLOAD_DIR, params[:filename])). If params[:filename] is ../../../../etc/passwd, File.join happily builds /var/app/uploads/../../../../etc/passwd, and File.read follows it. Rails, Sinatra, and plain Rack apps are all equally exposed because the vulnerability lives in application logic, not the framework. The impact ranges from reading /etc/passwd or config/database.yml to overwriting cron files or SSH authorized_keys on the write side. OWASP has listed path traversal under its Top 10 injection-adjacent risks for over a decade, and it remains one of the most common findings in Ruby codebases audited during SOC 2 and PCI assessments because it's cheap to introduce and easy to miss in code review — the line reads like ordinary file-handling code.
Why isn't File.expand_path enough to stop path traversal?
File.expand_path isn't enough because it normalizes .. and . segments syntactically but never checks the filesystem, so it will confidently return a path outside your allowed directory without complaint. Calling File.expand_path("../../etc/passwd", "/var/app/uploads") returns /etc/passwd — a clean, absolute, traversal-free-looking string that is nonetheless the exact file the attacker wanted. Many Ruby developers use expand_path as their entire defense, then compare the result with start_with? against the base directory, which is closer but still breaks on a second, subtler issue: symlinks. If /var/app/uploads/public_link is a symlink pointing to /etc, expand_path will report a path that starts with /var/app/uploads/ even though the file it resolves to on disk lives entirely outside the sandbox. This exact symlink-based bypass is what made the 2018 "Zip Slip" vulnerability (tracked in the rubyzip gem as CVE-2018-1000544) dangerous across dozens of libraries in multiple languages — archive extraction code validated the string path, not the real, symlink-resolved destination.
How does File.realpath actually prevent path traversal attacks?
File.realpath prevents path traversal by asking the operating system to resolve every .., ., and symlink in a path and return the actual canonical location on disk, which you can then safely compare against a known-good root. The pattern looks like this:
SAFE_ROOT = File.realpath("/var/app/uploads")
def safe_path(user_input)
requested = File.realpath(File.join(SAFE_ROOT, user_input))
raise SecurityError, "path traversal blocked" unless requested.start_with?(SAFE_ROOT + File::SEPARATOR)
requested
rescue Errno::ENOENT
raise SecurityError, "file not found"
end
Because File.realpath performs an actual filesystem lookup rather than string manipulation, it collapses symlinks along the way — if public_link really does point to /etc, File.realpath returns /etc/passwd, which visibly fails the start_with? check against SAFE_ROOT. This is the same principle behind Java's Path.toRealPath(), Python's os.path.realpath combined with a containment check, and Go's filepath.EvalSymlinks — canonicalize first, then compare, never the reverse. The critical detail teams get wrong is comparing against a base directory string that was never itself run through realpath; if SAFE_ROOT is a symlink, the comparison can pass even when the resolved file has escaped.
What real-world Ruby CVEs could File.realpath-style checks have mitigated?
Several high-profile Ruby ecosystem CVEs trace directly back to missing or incomplete real-path validation. CVE-2018-3760 in Sprockets (fixed in versions 3.7.2 and 2.12.5) allowed attackers to read arbitrary files through the asset pipeline's static file server because the path-matching logic checked the requested string against allowed extensions and root paths without canonicalizing symlinks first. CVE-2019-5418 in Action View, disclosed in March 2019, let attackers exfiltrate arbitrary file contents from a Rails app by manipulating the Accept header on a route using render file: with a partially attacker-controlled path — again a case of trusting a joined path string over a resolved, verified one. And CVE-2018-1000544 in rubyzip, the Zip Slip issue, affected archive extraction: a malicious zip entry named ../../../../home/user/.ssh/authorized_keys would extract outside the target directory because the extraction code never confirmed the final resolved path stayed inside the destination folder. All three were patched by adding exactly the canonicalize-then-contain check that File.realpath enables.
What are the limitations of File.realpath in production code?
File.realpath's main limitation is that it raises Errno::ENOENT for any path that doesn't yet exist, which makes it awkward for write operations like file uploads where the destination file is being created for the first time. Developers work around this by calling File.realpath on the parent directory instead of the full target path, then joining the filename back on and validating the parent's resolved location — you're still canonicalizing the part of the path that could contain a symlink attack, while allowing the leaf filename to not exist yet. A second limitation is time-of-check-to-time-of-use (TOCTOU) risk: between validating a path with File.realpath and actually opening the file, an attacker with local filesystem access could swap a symlink in, though this is a much narrower attack surface than the traversal bug itself and typically requires local code execution to exploit. Third, File.realpath is filesystem-dependent — behavior around case sensitivity, mounted network drives, and Windows drive letters differs from POSIX systems, so path validation logic written and tested only on Linux can behave differently in a Windows or NFS-backed CI environment. None of these are reasons to avoid File.realpath; they're reasons to pair it with strict input allowlisting (reject .. and null bytes outright before resolution) and least-privilege file permissions as defense in depth.
How should teams implement path traversal prevention correctly in Ruby?
Correct implementation means canonicalizing both the base directory and the requested path with File.realpath, comparing with a trailing separator to avoid prefix-matching mistakes (/var/app/uploads-backup should never pass a check against /var/app/uploads), and rejecting the request outright — not silently truncating it — the moment the check fails. Teams should also reject raw user input containing \0 null bytes or ../ sequences before ever building a path, since some older Ruby and C library versions have had null-byte truncation bugs that let attackers terminate a path string early. For file uploads where the target doesn't exist yet, validate the parent directory's real path and generate the filename server-side (a UUID, not user input) rather than trusting an uploaded filename at all. Static analysis tools like Brakeman flag File.join and File.read calls fed by params as medium-to-high severity findings specifically because this pattern recurs so often in Rails codebases — teams running Brakeman in CI catch a meaningful share of these before merge, but Brakeman's pattern matching won't catch traversal introduced through gems, background jobs, or file paths that pass through several method calls before reaching a File operation.
How Safeguard Helps
Safeguard's software supply chain platform is built for exactly this gap between "a linter flagged something" and "we know which of our 40 Ruby services actually has an exploitable path traversal issue, in which gem version, reachable from which endpoint." We continuously track disclosed CVEs like CVE-2018-3760, CVE-2019-5418, and CVE-2018-1000544 against your dependency graph and Gemfile.lock, so if a service is still running a vulnerable Sprockets or rubyzip version, you find out from Safeguard before an auditor or an attacker does. Beyond dependency matching, Safeguard's code-level analysis identifies risky patterns in your own application code — unsanitized File.join, File.read, and archive extraction calls fed by user input — and correlates them with reachability data so security teams can prioritize the handful of genuinely exploitable paths instead of triaging every static-analysis hit. For teams under SOC 2 or PCI scope, Safeguard also generates the evidence trail auditors ask for: which services were scanned, when, against what CVE and CWE coverage, and how remediation was tracked to closure. If your Ruby services touch file uploads, asset serving, template rendering, or archive extraction, Safeguard gives you continuous visibility into path traversal risk across your entire fleet rather than a one-time snapshot from your last pentest.