Filesystem Security
Why the Filesystem Needs Protection In simple terms: Your server's disk holds more than your web pages: configuration files with database passwords, private uploads, logs, and system files. Whenever P
Why the Filesystem Needs Protection
In simple terms: Your server's disk holds more than your web pages: configuration files with database passwords, private uploads, logs, and system files. Whenever PHP code reads, writes, includes, or deletes files based on user input, an attacker may try to reach files they should never see — for example, by requesting ../../config/.env. Filesystem security keeps file operations confined to the folders and files they are meant for.
Technically: The main risks are path traversal (using ../ or absolute paths to escape an intended directory), file inclusion (making include load attacker-chosen files), insecure permissions (files readable or writable by the wrong users or processes), race conditions between checking and using a file, and dangerous stream wrappers and functions. Defenses combine strict input validation, path resolution with realpath(), least-privilege permissions, and PHP configuration limits.
Path Traversal
The Vulnerability
// download.php?file=invoice-501.pdf — VULNERABLE
readfile('/var/www/shop/storage/invoices/' . $_GET['file']);
A request such as ?file=../../.env resolves to /var/www/shop/.env — the file with the database password. On Windows, backslashes and drive letters offer further variations.
The Defense: Validate the Name and Check the Resolved Path
<?php
function resolveInvoice(string $baseDir, string $requested): string
{
if (!preg_match('/^invoice-\d{1,10}\.pdf$/', $requested)) {
return 'rejected (invalid name)';
}
$base = realpath($baseDir);
$path = realpath($base . DIRECTORY_SEPARATOR . $requested);
if ($base === false || $path === false) {
return 'not found';
}
if (!str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
return 'rejected (outside the invoice folder)';
}
return 'allowed: ' . basename($path);
}
$baseDir = sys_get_temp_dir() . '/invoices-demo';
@mkdir($baseDir);
file_put_contents("$baseDir/invoice-501.pdf", '%PDF-demo');
$requests = [
'valid name' => 'invoice-501.pdf',
'traversal' => '../../etc/passwd',
'missing file' => 'invoice-999.pdf',
'NUL byte trick' => "invoice-501.pdf\0.txt",
];
foreach ($requests as $label => $requested) {
echo str_pad($label, 15) . '→ ' . resolveInvoice($baseDir, $requested) . "\n";
}
Output
valid name → allowed: invoice-501.pdf
traversal → rejected (invalid name)
missing file → not found
NUL byte trick → rejected (invalid name)
How It Works
- Strict validation first: the name must match exactly the expected pattern.
../, slashes, other extensions, and hidden characters never pass. realpath()resolves.,.., and symbolic links into the canonical absolute path, and returnsfalsefor files that do not exist.- Containment check: the resolved path must begin with the base directory plus a separator (otherwise
/invoices-demo-secret/...would pass a check for/invoices-demo). - Old attacks using NUL bytes (
file.pdf\0.php) to cut strings short in C functions no longer work in PHP 8, which rejects paths containing NUL bytes — but validating names strictly still stops them early.
Even better: identify files by database ID, check that the current user may access that record, and build the path only from values your application generated (see File Upload Security).
File Inclusion
Including files based on input is the most dangerous variant of path traversal, because the included file is executed:
// VULNERABLE
include __DIR__ . '/pages/' . $_GET['page'] . '.php';
Use an allow-list that maps input to fixed files (see PHP Include):
$pages = ['home' => 'home.php', 'about' => 'about.php'];
$page = $pages[$_GET['page'] ?? 'home'] ?? 'home.php';
include __DIR__ . '/pages/' . $page;
Keep allow_url_include = Off (the default), so include can never load code from remote URLs.
File Permissions and Ownership
The web server's PHP process (often www-data) should have only the access it needs:
| Path | Typical Permissions | Reason |
|---|---|---|
Application code (src/, public/, vendor/) | Owned by a deploy user; 0644 files, 0755 directories; not writable by PHP | An attacker who gains code execution cannot modify the application |
Configuration with secrets (.env) | 0640, readable by PHP's group only; outside the web root | Not downloadable, not readable by other users |
Writable data (storage/, cache/, uploads) | Writable by PHP, e.g., 0750 directories, 0640 files | Required for operation; never executable |
| Logs | Writable by PHP, readable by administrators | Logs often contain sensitive information |
Practical Rules
- Never use
chmod 0777. It lets every user and process on the server read and modify the files. - Keep only
public/inside the web server's document root; configuration, storage, logs, andvendor/stay outside. - Make sure dotfiles (
.env,.git/) are never served — moving them out of the document root is the reliable solution.
Race Conditions: Check-Then-Use
Checking a file and then using it in a separate step leaves a gap in which another process (or attacker) can change it — a time-of-check to time-of-use (TOCTOU) problem:
// Race: the file might be created between the check and the write
if (!file_exists($path)) {
file_put_contents($path, $data);
}
// Atomic: fopen() with 'x' fails if the file already exists
$handle = fopen($path, 'x');
if ($handle !== false) {
fwrite($handle, $data);
fclose($handle);
}
Use atomic operations where possible: fopen() mode x, flock(), writing to a temporary file and rename()-ing it (see PHP File Create/Write), and tempnam() / tmpfile() for temporary files with unpredictable names.
PHP Configuration Limits
| Setting | Purpose |
|---|---|
open_basedir = /var/www/shop:/tmp | Restricts all PHP file operations to listed directories — a useful extra boundary, especially on shared servers |
allow_url_include = Off | Prevents including remote code |
allow_url_fopen | Allows file_get_contents('https://...'); if enabled, never pass user-controlled URLs (SSRF risk) |
disable_functions = exec,shell_exec,system,passthru,proc_open,popen | Removes shell access functions the application does not need |
If the application must run external programs, pass every argument through escapeshellarg() — or, better, use proc_open() with an array of arguments (PHP 7.4+), which bypasses the shell entirely.
Stream Wrappers
PHP's file functions also accept stream wrappers such as php://, data://, phar://, http://, and ftp://. If a user-controlled string is passed to file_get_contents(), fopen(), include, or image functions, an attacker may use these wrappers to read local resources, make network requests, or (in older PHP versions) trigger unserialization through phar:// archives. PHP 8.0 no longer unserializes Phar metadata automatically, but the rule remains: never pass user input as a path or URL without strict validation.
Common Mistakes
Concatenating User Input Into Paths
Effect: Path traversal and file disclosure.
Fix: Validate names strictly, resolve with realpath(), and check containment — or use database IDs.
World-Writable Permissions
Effect: Any process can modify or plant files.
Fix: Grant minimal permissions; keep code read-only for PHP.
Secrets Inside the Web Root
Effect: .env, backups, or .git/ are downloadable.
Fix: Keep only public/ in the document root.
Check-Then-Use Without Atomic Operations
Effect: Race conditions and overwritten files.
Fix: Use exclusive modes, locks, and atomic renames.
Including Files From Input
Effect: Remote or local code execution.
Fix: Map input to an allow-list of files.
Best Practices
- Never build filesystem paths directly from user input; validate, resolve, and confine them.
- Keep the document root minimal and store secrets, storage, and logs outside it.
- Apply least-privilege ownership and permissions; avoid
0777. - Use atomic file operations and secure temporary files.
- Harden PHP with
open_basedir,allow_url_include = Off, and disabled shell functions where possible.
Official reference: https://www.php.net/manual/en/security.filesystem.php