File and Path Safety
Keep user input out of file paths: resolve against a real base directory, never include it, and stop uploads from executing.
$full = realpath($base . '/' . basename($name)); // then: str_starts_with($full, realpath($base) . DIRECTORY_SEPARATOR)A filename from a request is input like any other. If you append it to a directory and open the result, the request decides which file you read. ../ means "the directory above", so ?file=../../../../etc/passwd walks out of your storage folder and into the system. This is path traversal.
Resolve, then check
String checks on the input fail, because there are too many spellings: encoded slashes, doubled dots, a leading slash. Resolve the path instead and then ask where it landed. basename() throws away any directory part the user sent. realpath() turns what is left into one absolute, symlink-free path. Compare that result against realpath($base) . DIRECTORY_SEPARATOR with str_starts_with(), and refuse anything outside.
Never include user input
include and require execute what they load. A page name taken from the URL and passed to require lets a visitor run any PHP file on the disk, including one they uploaded a minute ago. Use a fixed array that maps a key to a path, and look the key up. If it is not in the array, serve the default.
Uploads are files you did not write
Move an upload with move_uploaded_file(), give it a name you generated, and decide its type from its contents rather than from $_FILES['x']['type'], which the browser sends and anyone can set. Then put the directory outside the web root, or configure the server not to run PHP in it. An uploads folder that executes is a shell.
Example
When to use it
- Serving an invoice or a report that only the account it belongs to may download.
- Routing ?page=pricing to a template without letting it load arbitrary files.
- Accepting a profile picture or a PDF attachment from a signed-up user.
- Reading a CSV from a folder of imports where the filename comes from a form.
More examples
Serving a private download
This is why the storage directory sits outside the web root: the only way to reach a file is through this script, which can check both the path and the owner. Returning 404 rather than 403 for a path outside the base avoids confirming that anything is there. Send the headers before readfile(), because once bytes are out the headers are already gone.
<?php
declare(strict_types=1);
$base = '/var/app/storage/invoices';
$name = basename($_GET['file'] ?? '');
$full = realpath($base . '/' . $name);
if (
$full === false
|| !str_starts_with($full, realpath($base) . DIRECTORY_SEPARATOR)
|| !is_file($full)
) {
http_response_code(404);
echo 'Not found';
exit;
}
// Check the file belongs to the logged-in user before this point.
header('Content-Type: application/pdf');
header('Content-Length: ' . filesize($full));
header('Content-Disposition: attachment; filename="' . $name . '"');
readfile($full);A page router that cannot include anything else
The commented-out line is the classic local file inclusion bug, and it is still common in small hand-written sites. The map version is safe because the request only ever selects an array key, and an unknown key falls back to the default. The same pattern works for templates, locale files and config fragments.
<?php
declare(strict_types=1);
// The only files this router will ever load.
$pages = [
'home' => 'pages/home.php',
'pricing' => 'pages/pricing.php',
'contact' => 'pages/contact.php',
];
$key = $_GET['page'] ?? 'home';
// NOT: require __DIR__ . '/pages/' . $_GET['page'] . '.php';
require __DIR__ . '/' . ($pages[$key] ?? $pages['home']);Storing an upload under a name you chose
Three separate decisions are taken away from the uploader here: the name, the extension and the type. A generated name removes traversal and collisions in one step, and the extension comes from your own map rather than from the original filename, so shell.php cannot arrive with a .php tail. Keep the original name in a database column if you need to show it, and keep $dir out of the web root.
<?php
declare(strict_types=1);
function storeUpload(array $file, string $dir): string
{
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload failed');
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException('Not an uploaded file');
}
// Read the type from the bytes, not from $file['type'],
// which the browser sends and anyone can forge.
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extensions = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
];
if (!isset($extensions[$mime])) {
throw new RuntimeException('Unsupported file type');
}
$target = $dir . '/' . bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException('Could not store upload');
}
return $target;
}
Discussion