Защита файлового доступа в 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(), если файл находится внутри разрешённой директории. Кроме того, возможны проблемы с производительностью при большом количестве разрешённых путей.

- права php (управление правами доступа в php)
- Php пароль mysql (пароль для mysql в php)
- Domain block php (блокировка домена в php)

Расширенные примеры безопасного доступа к файлам

Пример 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, он не сможет модифицировать параметры без знания секретного ключа.

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

En
Access php (php)