PHP и файловая система: стратегии безопасности
Основные подходы к безопасному доступу к файлам
Как предотвратить атаки path traversal и доступ к конфиденциальным файлам?
Наиболее эффективное решение основано на проверке реального пути файла с помощью realpath() и последующем сравнении с разрешённой базовой директорией (белый список). Такой подход исключает возможность выхода за пределы заданного каталога, даже при использовании символических ссылок или кодирования.
function safeFilePath(string $baseDir, string $userPath): ?string {
$baseDir = rtrim(realpath($baseDir), '/\\');
if (!$baseDir) {
return null;
}
$userPath = str_replace(['..', './', '\\'], '/', $userPath);
$userPath = ltrim($userPath, '/');
$fullPath = $baseDir . '/' . $userPath;
$realPath = realpath($fullPath);
if ($realPath === false || strpos($realPath, $baseDir) !== 0) {
return null;
}
if (!is_file($realPath) || !is_readable($realPath)) {
return null;
}
return $realPath;
}
Функция сначала вычисляет реальный базовый путь, затем очищает пользовательский ввод от элементов обхода (.. и .), после чего проверяет, что итоговый realpath() начинается с базовой директории. Только после этого выполняется чтение файла.
Типичные проблемы и ошибки:
- Символические ссылки могут указывать за пределы базовой директории. Функция realpath() разрешает их, поэтому проверка strpos остаётся корректной.
- Использование null-байтов (например, file\0.php) в старых версиях PHP могло обрезать строку. Современные версии обрабатывают такие символы как обычные, но лучше явно удалять управляющие символы.
- Разные операционные системы имеют разный разделитель путей. В примере приведена нормализация к /.
Как безопасно прочитать содержимое файла, который может быть недоступен?
Простое чтение с помощью file_get_contents() часто используется, но требует предварительных проверок. Рекомендуется комбинировать проверку существования и прав доступа.
$filename = 'config/secret.txt';
if (file_exists($filename) && is_readable($filename)) {
$content = file_get_contents($filename);
echo $content;
} else {
// Логирование ошибки, но не раскрытие пути
error_log('File access failed: ' . $filename);
}
Важно не выводить пользователю детали ошибки, чтобы не раскрывать структуру файловой системы.
- Условие гонки (race condition): между проверкой file_exists/is_readable и чтением файл может быть изменён или удалён. Для критичных операций лучше сразу пробовать чтение и обрабатывать ошибки.
- Большие файлы: file_get_contents загружает весь файл в память, что может привести к исчерпанию памяти. Для больших файлов используйте fopen и чтение частями.
Как избежать повреждения данных при конкурентной записи в файл?
Использование блокировки файла (flock) или атомарной функции file_put_contents с флагом LOCK_EX.
$data = "Лог-сообщение\n";
$result = file_put_contents('log.txt', $data, FILE_APPEND | LOCK_EX);
if ($result === false) {
// Обработка ошибки
}
Флаг FILE_APPEND добавляет данные в конец, LOCK_EX устанавливает эксклюзивную блокировку на время записи.
- Блокировки не работают на некоторых файловых системах (например, NFS), поэтому полагаться только на них не стоит.
- Отсутствие снятия блокировки может привести к deadlock; использование file_put_contents снимает блокировку автоматически после записи.
Как подключать файлы через include без риска включения произвольного содержимого?
Пользовательский ввод никогда не должен напрямую участвовать в пути подключаемого файла. Вместо этого следует определить белый список разрешённых файлов или использовать маппинг.
$allowedFiles = [
'header' => '/var/www/templates/header.php',
'footer' => '/var/www/templates/footer.php',
];
$key = $_GET['section'] ?? '';
if (isset($allowedFiles[$key])) {
include $allowedFiles[$key];
} else {
// Ошибка: неизвестный файл
}
Дополнительно можно проверять расширение файла и использовать __DIR__ для указания базового каталога.
- Проблема: даже при использовании маппинга, если файл сам содержит уязвимости (например, отображает пользовательские данные без фильтрации), это может быть опасно.
- Ошибка: забыть закрыть доступ к файлам через прямой URL (через .htaccess или nginx).
Как предотвратить загрузку вредоносных файлов через форму?
После перемещения загруженного файла из временной директории с помощью move_uploaded_file() необходимо проверить MIME-тип, размер и, возможно, содержание файла.
$allowedMime = ['image/jpeg', 'image/png', 'application/pdf'];
$maxSize = 2 * 1024 * 1024; // 2 MB
if (isset($_FILES['upload'])) {
$file = $_FILES['upload'];
if ($file['error'] !== UPLOAD_ERR_OK) {
// Обработка ошибки загрузки
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowedMime, true)) {
// Недопустимый тип
}
if ($file['size'] > $maxSize) {
// Слишком большой файл
}
$dest = '/var/www/uploads/' . bin2hex(random_bytes(16)) . '_' . basename($file['name']);
if (move_uploaded_file($file['tmp_name'], $dest)) {
// Успех
}
}
В примере генерируется уникальное имя с помощью случайной строки, чтобы избежать перезаписи существующих файлов и предотвратить атаки на имя файла.
- Проверка MIME-типа на основе расширения ненадёжна, поэтому используется finfo.
- Атака двойного расширения (например, file.php.jpg) может обойти фильтрацию. Удаление нежелательных символов из имени обязательно.
- Директория загрузок должна быть настроена так, чтобы PHP-файлы не интерпретировались (через .htaccess или конфигурацию веб-сервера).
Как предоставить доступ к файлу только авторизованному пользователю?
Хранить файлы вне веб-корня и выдавать их через PHP-скрипт, который проверяет сессию пользователя.
session_start();
if (!isset($_SESSION['user'])) {
http_response_code(403);
exit('Доступ запрещён');
}
$filePath = '/var/private/files/' . basename($_GET['file']);
if (file_exists($filePath) && is_readable($filePath)) {
header('Content-Type: ' . mime_content_type($filePath));
header('Content-Disposition: attachment; filename="' . basename($filePath) . '"');
readfile($filePath);
}
Файлы не доступны напрямую через URL, так как находятся за пределами DocumentRoot.
- Необходимо проверять, что запрошенный файл не выходит за пределы разрешённой директории (использовать realpath и basename).
- Большие файлы могут вызвать проблемы с памятью при использовании readfile. Для больших объёмов лучше отправлять частями или использовать fpassthru.
Как запретить прямой доступ к файлам через URL с помощью .htaccess?
Если файлы должны быть доступны только через PHP, веб-сервер можно настроить на блокировку прямого доступа к определённым директориям.
# .htaccess в директории /protected/
Deny from all
Или для конкретных типов файлов:
Require all denied
- На серверах без поддержки .htaccess (например, nginx) необходимо использовать конфигурацию сервера.
- Ошибка: забыть, что .htaccess не влияет на скрипты, выполняемые внутри PHP.
Расширенные примеры безопасной работы с файлами
Кастомный Stream Wrapper для шифрования данных на лету
Создание собственного stream wrapper позволяет автоматически шифровать данные при записи и расшифровывать при чтении, не меняя логику приложения.
class AesStreamWrapper {
const PROTOCOL = 'aes';
private $fp;
private $key;
public function stream_open($path, $mode, $options, &$opened_path) {
$url = parse_url($path);
$file = $url['host'] . $url['path'];
$this->fp = fopen($file, $mode);
if (!$this->fp) return false;
$this->key = 'секретный_ключ_32_байта';
return true;
}
public function stream_read($count) {
$data = fread($this->fp, $count);
if ($data === false) return false;
return openssl_decrypt($data, 'aes-256-cbc', $this->key, 0, substr($this->key, 0, 16));
}
public function stream_write($data) {
$encrypted = openssl_encrypt($data, 'aes-256-cbc', $this->key, 0, substr($this->key, 0, 16));
return fwrite($this->fp, $encrypted);
}
// ... остальные методы интерфейса
}
stream_wrapper_register(AesStreamWrapper::PROTOCOL, AesStreamWrapper::class);
$data = file_get_contents('aes://secret.txt');
file_put_contents('aes://output.txt', 'конфиденциальный текст');
Результат: файл output.txt содержит зашифрованные данные, которые могут быть прочитаны только через этот же wrapper.
Обработка больших файлов построчно с помощью генератора
При работе с файлами размером в гигабайты недопустимо загружать весь файл в память. Генератор позволяет обрабатывать строку за строкой.
function readLines($filename) {
$fp = fopen($filename, 'rb');
if (!$fp) throw new RuntimeException('Не удалось открыть файл');
try {
while (!feof($fp)) {
$line = fgets($fp);
if ($line === false) break;
yield rtrim($line, "\r\n");
}
} finally {
fclose($fp);
}
}
foreach (readLines('/var/log/large.log') as $line) {
// Анализ строки без загрузки всего файла
if (strpos($line, 'ERROR') !== false) {
echo $line . PHP_EOL;
}
}
Результат: выводятся только строки, содержащие 'ERROR', при этом память используется минимально.
Мониторинг изменений в файловой системе с помощью inotify
Расширение inotify (только Linux) позволяет отслеживать события файловой системы (изменение, удаление, создание) без постоянного опроса.
$inotify = inotify_init();
$watch = inotify_add_watch($inotify, '/var/www/uploads', IN_MODIFY | IN_CREATE | IN_DELETE);
stream_set_blocking($inotify, false);
echo "Ожидание событий...\n";
while (true) {
$events = inotify_read($inotify);
if ($events) {
foreach ($events as $ev) {
echo "Событие: " . $ev['name'] . " mask=" . $ev['mask'] . PHP_EOL;
}
}
usleep(100000); // 0.1 сек
}
Результат: скрипт выводит имена файлов и типы событий, происходящих в указанной директории.
Безопасная выдача файлов с проверкой и заголовками
При скачивании файла через PHP важно правильно установить заголовки, чтобы избежать инъекций и неправильной интерпретации.
$file = '/var/data/report.pdf';
$name = basename($file);
if (!file_exists($file) || !is_readable($file)) {
http_response_code(404);
exit;
}
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($name) . '"');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . filesize($file));
ob_clean();
flush();
readfile($file);
Результат: браузер инициирует скачивание файла с оригинальным именем, защищённым от специальных символов через rawurlencode.