Защита файлового доступа в PHP
Основные принципы безопасного доступа к файлам в PHP
При работе с файловой системой в PHP критически важно контролировать пути, передаваемые пользователем. Некорректная обработка может привести к атакам типа Path Traversal, чтению произвольных файлов или выполнению вредоносного кода. В этой части статьи рассматриваются различные подходы к организации безопасного доступа к файлам.
Наиболее эффективное решение: строгая валидация и белый список разрешённых путей
Как гарантировать, что пользователь сможет получить доступ только к определённым файлам?
Самый надёжный способ - заранее определить список разрешённых файлов или каталогов и сравнивать с ним запрашиваемый путь после его нормализации. Это исключает любую возможность обхода.
<?php
$allowedFiles = [
'report.pdf' => '/var/data/reports/report.pdf',
'manual.txt' => '/var/data/docs/manual.txt'
];
$requested = $_GET['file'] ?? '';
if (isset($allowedFiles[$requested])) {
$path = $allowedFiles[$requested];
if (file_exists($path)) {
readfile($path);
} else {
echo 'Файл отсутствует.';
}
} else {
echo 'Доступ запрещён.';
}
?>
Access php (доступ к файлам в php)
В этом примере пользователь передаёт ключ (короткое имя), а не полный путь. Маппинг жёстко задан в коде, поэтому любые попытки передать ../../../etc/passwd бессмысленны.
Проблема: список разрешённых файлов нужно поддерживать и обновлять при добавлении новых файлов. Если файлов много, такой подход становится неудобным. Решение: хранить маппинг в отдельном конфигурационном файле или базе данных, но при этом строго валидировать входной ключ (только буквы, цифры, нижнее подчёркивание).
Вариант 1: Использование basename() и realpath() с проверкой префикса
Как разрешить пользователю указывать имя файла, но не допустить выхода за пределы определённой директории?
Метод заключается в том, чтобы взять только имя файла (без пути) с помощью basename(), затем построить полный путь внутри разрешённой корневой папки и применить realpath() для разрешения возможных символических ссылок и ... После этого проверяется, начинается ли полученный путь с разрешённого префикса.
<?php
$root = '/var/www/uploads';
$userInput = $_GET['file'] ?? '';
$filename = basename($userInput); // избавляемся от каталогов
$fullPath = $root . '/' . $filename;
$realPath = realpath($fullPath);
if ($realPath !== false && strncmp($realPath, $root, strlen($root)) === 0) {
if (is_file($realPath) && is_readable($realPath)) {
readfile($realPath);
} else {
echo 'Файл не найден или недоступен для чтения.';
}
} else {
echo 'Недопустимый путь.';
}
?>
Php filter (фильтрация данных в php)
Пояснение шагов: basename() удаляет любые компоненты пути, оставляя только имя файла. realpath() преобразует путь в абсолютный и разрешает символьные ссылки. Сравнение префикса гарантирует, что итоговый путь остаётся внутри $root.
Типичная ошибка: использование realpath() до добавления корневой директории. Если пользователь передаст /etc/passwd, то realpath('/var/www/uploads//etc/passwd') может вернуть false или неверный путь из-за особенностей файловой системы. Всегда сначала собирайте полный путь, затем вызывайте realpath().
Проблема: если внутри разрешённой директории существуют символические ссылки, указывающие наружу, realpath() разрешит их, и префиксная проверка может пропустить внешний файл. Для критических систем следует отключить символические ссылки или дополнительно проверять владельца inode.
Вариант 2: Фильтрация с помощью filter_var() и регулярных выражений
Как разрешить только определённые символы в имени файла?
Иногда достаточно ограничить входные данные на уровне строки: разрешить только буквы, цифры, дефис и точку.
<?php
$userInput = $_GET['file'] ?? '';
if (preg_match('/^[a-zA-Z0-9_\-\.]+$/', $userInput)) {
$path = '/var/data/' . $userInput;
if (is_file($path) && is_readable($path)) {
readfile($path);
} else {
echo 'Файл не найден.';
}
} else {
echo 'Недопустимые символы в имени файла.';
}
?>
Php пароль (работа с паролями в php)
Данный метод прост, но не защищает от ситуаций, когда файл уже существует с нежелательным содержимым. Кроме того, если требуется поддержка вложенных каталогов (например, docs/report.pdf), регулярное выражение должно быть расширено для допуска символа / - что сразу снижает безопасность.
Проблема: регулярное выражение может быть обойдено с помощью URL-кодирования или двойной кодировки. Всегда дополнительно применяйте urldecode() с осторожностью и проверяйте окончательный путь.
Вариант 3: Использование open_basedir на уровне сервера
Как ограничить доступ к файловой системе для всего скрипта без изменения кода?
Директива open_basedir в конфигурации PHP ограничивает все операции с файлами указанными директориями. Это работает как глобальная клетка для скрипта.
; php.ini
open_basedir = /var/www/html:/var/lib/php
Если скрипт попытается открыть файл вне этих путей, вызов завершится ошибкой.
Проблема: open_basedir не стоит рассматривать как единственную меру безопасности - он не защищает от атак типа LFI через функции вроде include(), если файл находится внутри разрешённой директории. Кроме того, возможны проблемы с производительностью при большом количестве разрешённых путей.
Расширенные примеры безопасного доступа к файлам
Пример 1: Безопасное скачивание файлов с контролем MIME-типа
Допустим, пользователь может скачивать только изображения из каталога /var/www/uploads/images. Используется белый список расширений, проверка реального MIME-типа через finfo и нормализация пути.
<?php
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$root = '/var/www/uploads/images';
$input = $_GET['file'] ?? '';
$basename = basename($input);
$extension = strtolower(pathinfo($basename, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions)) {
die('Недопустимое расширение файла.');
}
$fullPath = $root . '/' . $basename;
$realPath = realpath($fullPath);
if ($realPath === false || strncmp($realPath, $root, strlen($root)) !== 0) {
die('Путь вне разрешённой директории.');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $realPath);
finfo_close($finfo);
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($mime, $allowedMimes)) {
die('Реальный MIME-тип не соответствует ожидаемому.');
}
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . $basename . '"');
readfile($realPath);
?>
Результат: пользователь не сможет скачать shell.php с расширением .php, даже если файл случайно оказался в папке с картинками. Расширение проверяется, но если злоумышленник переименует файл в shell.jpg с PHP-кодом внутри, MIME-тип выявит подмену.
Пример 2: Загрузка файлов с контролем размера и созданием безопасного имени
Рассмотрим сценарий загрузки файла на сервер с заменой имени на хеш, чтобы избежать коллизий и обхода path traversal.
<?php
$uploadDir = '/var/www/uploads/files';
$maxSize = 5 * 1024 * 1024; // 5 MB
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['uploaded_file'])) {
$file = $_FILES['uploaded_file'];
// Проверка размера
if ($file['size'] > $maxSize) {
die('Файл слишком большой. Максимальный размер: 5 МБ.');
}
// Проверка MIME через finfo (не доверяем клиентскому типу)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$allowed = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
if (!in_array($mime, $allowed)) {
die('Недопустимый тип файла.');
}
// Генерация безопасного имени
$extension = 'pdf'; // или извлекать из реального MIME, но не из имени
$safeName = bin2hex(random_bytes(16)) . '.' . $extension;
$destination = $uploadDir . '/' . $safeName;
if (move_uploaded_file($file['tmp_name'], $destination)) {
echo 'Файл успешно загружен. Имя: ' . $safeName;
} else {
echo 'Ошибка при загрузке.';
}
}
?>
Пояснение: случайное имя исключает возможность предугадать путь к файлу и атаку на него. Расширение выбирается на основе реального MIME, а не из переданного имени (которое может быть подделано).
Пример 3: Использование PHP streams для изоляции файлового доступа
PHP stream wrapper может быть полезен для создания виртуальной файловой системы. Рассмотрим применение php:// и пользовательского stream wrapper для ограничения доступа.
<?php
// Пример с использованием php://filter для чтения только определённых файлов
$file = '/var/www/data/secret.txt';
// Но если разрешить произвольные пути, злоумышленник может прочитать любой файл.
// Поэтому лучше использовать белый список
$allowed = ['secret.txt', 'readme.txt'];
$input = $_GET['file'] ?? '';
if (in_array($input, $allowed)) {
$fullPath = '/var/www/data/' . basename($input);
readfile($fullPath);
} else {
echo 'Доступ запрещён.';
}
?>
Результат: stream-подход сам по себе не даёт преимуществ в безопасности, если не сочетать его с валидацией. Но можно создать свой stream wrapper, который будет проверять права доступа на низком уровне.
Пример 4: Ограничение доступа через crypt() или подпись URL
Иногда требуется дать временный доступ к файлам. Можно генерировать одноразовые подписанные ссылки.
<?php
$secret = 'my_secret_key_123';
$fileId = 42;
$expires = time() + 3600;
$signature = md5($secret . $fileId . $expires);
$link = "download.php?file=$fileId&expires=$expires&sig=$signature";
echo "Ссылка для скачивания: $link";
// В download.php:
$expected = md5($secret . $fileId . $expires);
if ($_GET['sig'] === $expected && time() < $expires) {
// ... безопасное чтение файла
} else {
echo 'Ссылка недействительна.';
}
?>
Результат: ссылка действительна только в течение часа. Даже если злоумышленник перехватит URL, он не сможет модифицировать параметры без знания секретного ключа.