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.

Доступ к файлу в PHP - comments

En
Php доступ к файлу (php)