Files
progcode/web/scripts/upgrade-2018-03.mjs
huncode 7c5b19c960
Build and deploy / deploy (push) Successful in 18s
edit full article archive to publication standard
2026-07-31 23:08:19 +03:00

413 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const escapeHtml = (value) => String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const paragraph = (content) => '<p>' + content + '</p>';
const heading = (content) => '<h2>' + content + '</h2>';
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
const figure = (src, alt, caption) => [
'<figure>',
'<img src="' + src + '" alt="' + alt + '" />',
'<figcaption>' + caption + '</figcaption>',
'</figure>',
].join('');
function dataTable(headers, rows) {
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
const body = rows.map((row) => (
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
)).join('');
return '<div class="table-scroll"><table><thead><tr>' + head
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function sourceList(sources) {
return '<ul>' + sources.map(({ title, url }) => (
'<li><a href="' + url + '" target="_blank" rel="noopener">' + title + '</a></li>'
)).join('') + '</ul>';
}
const phpUploadErrors = {
title: 'PHP Manual: коды ошибок загрузки',
url: 'https://www.php.net/manual/en/features.file-upload.errors.php',
};
const phpMoveUploadedFile = {
title: 'PHP Manual: move_uploaded_file',
url: 'https://www.php.net/manual/en/function.move-uploaded-file.php',
};
const phpFileinfo = {
title: 'PHP Manual: finfo_file',
url: 'https://www.php.net/manual/en/function.finfo-file.php',
};
const phpGetImageSize = {
title: 'PHP Manual: getimagesize и его ограничение как валидатора',
url: 'https://www.php.net/manual/en/function.getimagesize.php',
};
const phpHeader = {
title: 'PHP Manual: header',
url: 'https://www.php.net/manual/en/function.header.php',
};
const phpReadfile = {
title: 'PHP Manual: readfile',
url: 'https://www.php.net/manual/en/function.readfile.php',
};
const multipartRfc = {
title: 'RFC 7578: multipart/form-data',
url: 'https://www.rfc-editor.org/rfc/rfc7578',
};
const contentDispositionRfc = {
title: 'RFC 6266: Content-Disposition в HTTP',
url: 'https://www.rfc-editor.org/rfc/rfc6266',
};
const owaspUpload = {
title: 'OWASP File Upload Cheat Sheet',
url: 'https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html',
};
const practiceArticle = {
slug: 'editorial-2018-03-practice-safe-uploads',
title: 'PHP. Безопасная загрузка аватара: минимальный маршрут без доверия к имени файла',
categories: ['PHP', 'Безопасность'],
cover: '/assets/editorial/2018/php-upload-avatar-contract.svg',
excerpt: 'Собираем маленький обработчик для JPEG и PNG: проверяем доставку, размер и содержимое, сохраняем под своим именем и не отдаём путь из веб-корня.',
readingMinutes: 9,
contentHtml: [
paragraph('Загрузка аватара обычно начинается с одного поля формы и вызова <code>move_uploaded_file</code>. Ошибка становится заметна позже: каталог <code>uploads</code> оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к <code>.jpg</code>. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.'),
paragraph('Вопрос этой заметки один: <strong>как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности?</strong> Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения. Цена ошибки — файл в веб-корне, который можно открыть или выполнить не по назначению.'),
heading('Сначала договоримся о результате'),
paragraph('Форма передаёт один файл <code>avatar</code>. Мы принимаем не более 2 МБ, только <code>image/jpeg</code> и <code>image/png</code>, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например <code>7f4a...c2.png</code>. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.'),
figure(
'/assets/editorial/2018/php-upload-avatar-contract.svg',
'Путь файла аватара: браузер передаёт multipart-часть, PHP создаёт временный файл, код проверяет его и переносит в закрытое хранилище под сгенерированным ключом.',
'Проверки идут до переноса. После переноса остаётся ключ приложения, а не имя из формы.',
),
dataTable(
['Проверка', 'Что она отвечает', 'Что делаем при отказе'],
[
['<code>UPLOAD_ERR_OK</code>', 'PHP полностью принял часть запроса', 'Не читаем временный путь, показываем понятную ошибку загрузки'],
['Лимит 2 МБ', 'Файл укладывается в договор аватара', 'Не переносим файл и не пытаемся уменьшать его вслепую'],
['<code>finfo_file</code>', 'Какой MIME-тип определён по временному файлу', 'Отклоняем тип, которого нет в белом списке'],
['Размеры изображения', 'Подходит ли картинка для интерфейса', 'Отклоняем слишком маленькое или слишком большое изображение'],
['Сгенерированный ключ', 'Куда именно будет записан файл', 'Никогда не составляем путь из исходного имени'],
],
),
heading('Обработчик без скрытого шага'),
paragraph('Проверка <code>$_FILES["avatar"]["error"]</code> должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле <code>type</code> из <code>$_FILES</code> здесь намеренно не используется: его прислал клиент.'),
codeBlock(String.raw`
<?php
function storeAvatar(array $file, string $privateDir): array
{
if (!isset($file['error'], $file['tmp_name'], $file['size'])) {
throw new RuntimeException('Поле avatar передано в неверном формате');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('PHP не принял файл: код ' . $file['error']);
}
$maxBytes = 2 * 1024 * 1024;
if ((int)$file['size'] > $maxBytes) {
throw new RuntimeException('Аватар больше 2 МБ');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
throw new RuntimeException('Расширение Fileinfo недоступно');
}
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mime) || !isset($allowed[$mime])) {
throw new RuntimeException('Нужен JPEG или PNG');
}
$size = getimagesize($file['tmp_name']);
if ($size === false) {
throw new RuntimeException('Не удалось прочитать размеры изображения');
}
list($width, $height) = $size;
if ($width < 64 || $height < 64 || $width > 3000 || $height > 3000) {
throw new RuntimeException('Размеры изображения вне допустимого диапазона');
}
$storageKey = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$target = rtrim($privateDir, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . $storageKey;
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException('Не удалось сохранить аватар');
}
return [
'storageKey' => $storageKey,
'mime' => $mime,
'width' => $width,
'height' => $height,
];
}
`),
heading('Почему порядок проверок важнее набора функций'),
paragraph('У <code>move_uploaded_file</code> есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам.'),
paragraph('Вызов <code>getimagesize</code> нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.'),
heading('Минимальная форма и проверка руками'),
codeBlock(String.raw`
<form method="post" enctype="multipart/form-data" action="/profile/avatar.php">
<input type="file" name="avatar" accept="image/jpeg,image/png" required>
<button type="submit">Сохранить аватар</button>
</form>
`),
paragraph('Атрибут <code>accept</code> помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением <code>.jpg</code> и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.'),
heading('Порядок запуска'),
orderedList([
'Создать отдельный каталог для файлов за пределами веб-корня и дать PHP права только на нужную операцию записи.',
'Подключить форму с <code>multipart/form-data</code> и передать <code>$_FILES["avatar"]</code> в функцию.',
'После успешного вызова сохранить только <code>storageKey</code>, MIME-тип и размеры рядом с пользователем.',
'Проверить отрицательные сценарии: при любой ошибке ни файл, ни ссылка на него не должны появиться в профиле.',
'Отдельно решить, как читать аватар пользователю: прямой URL подходит лишь для действительно публичной картинки.',
]),
heading('Граница этого примера'),
paragraph('Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.'),
heading('Проверяемые источники'),
sourceList([phpUploadErrors, phpMoveUploadedFile, phpFileinfo, phpGetImageSize, owaspUpload]),
].join('\n'),
};
const mechanismArticle = {
slug: 'editorial-2018-03-mechanism-safe-uploads',
title: 'PHP. Почему расширение и Content-Type не отвечают на вопрос «что за файл?»',
categories: ['PHP', 'Безопасность'],
cover: '/assets/editorial/2018/php-upload-trust-signals.svg',
excerpt: 'Разбираем, какие сведения о загрузке пришли от клиента, какие получил PHP и где серверу действительно стоит принимать решение о допустимом файле.',
readingMinutes: 9,
contentHtml: [
paragraph('Симптом: обработчик пропускает файл с <code>type=image/jpeg</code>, хотя Fileinfo для временного файла определяет другой тип. Цена ошибки — приложение сохраняет и позднее выдаёт контент, которого этот маршрут не должен был принимать. Самая коварная строка в обработчике загрузки выглядит безобидно: <code>if ($file["type"] === "image/jpeg")</code>. Она работает с обычным браузером и ломает модель в тот момент, когда запрос собран не браузером. В multipart-форме имя файла и Content-Type — часть сообщения клиента. Сервер получает эти поля, но не обязан считать их доказательством содержимого.'),
paragraph('Главный вопрос статьи: <strong>какие признаки файла можно использовать для какой проверки?</strong> Ответ не сводится к одной «правильной» функции. У доставки, типа, размеров и имени разные источники, поэтому их нельзя склеивать в одну проверку с красивым названием <code>validateUpload()</code>.'),
heading('Где заканчиваются сведения клиента'),
paragraph('RFC 7578 описывает <code>multipart/form-data</code>: файл приходит отдельной частью с заголовками, среди которых может быть Content-Type. Это формат передачи, а не подпись под содержимым. PHP раскладывает результат в <code>$_FILES</code>; там есть исходное имя, клиентский тип, размер, временный путь и код ошибки. У каждого поля своя ценность.'),
figure(
'/assets/editorial/2018/php-upload-trust-signals.svg',
'Схема границ доверия: имя и Content-Type идут от клиента, PHP сообщает результат доставки, Fileinfo изучает временный файл, а приложение применяет собственный белый список.',
'Клиентские метаданные полезны для интерфейса и диагностики. Решение о допуске принимает приложение после проверки временного файла.',
),
dataTable(
['Сигнал', 'Откуда он взялся', 'Правильное применение'],
[
['<code>$file["name"]</code>', 'Имя, переданное клиентом', 'Показать как подпись после экранирования; не строить из него путь'],
['Расширение', 'Часть клиентского имени', 'Использовать как удобный фильтр интерфейса, но не как доказательство типа'],
['<code>$file["type"]</code>', 'Content-Type multipart-части', 'Сохранить в отладочном журнале, но не использовать для допуска'],
['<code>$file["error"]</code>', 'Результат, который сообщил PHP', 'Продолжать только при <code>UPLOAD_ERR_OK</code>'],
['<code>finfo_file()</code>', 'Анализ временного файла на сервере', 'Сравнить с точным белым списком допустимых MIME-типов'],
['<code>getimagesize()</code>', 'Попытка прочитать параметры изображения', 'Проверить размеры после Fileinfo, но не считать это проверкой безопасности'],
],
),
heading('Короткий опыт на локальной машине'),
paragraph('Ниже не нужен вредоносный файл. Достаточно обычного текста и вручную заданного Content-Type. Поднимите встроенный сервер PHP в каталоге с <code>inspect.php</code>, отправьте файл через <code>curl</code> и посмотрите на два значения. Конкретный MIME-результат Fileinfo может зависеть от его базы, но он определяется по временному файлу, а не по параметру <code>type=image/jpeg</code> в команде.'),
codeBlock(String.raw`
<?php
// inspect.php
$file = $_FILES['avatar'] ?? [];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('Файл не получен');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detected = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
if ($finfo) {
finfo_close($finfo);
}
header('Content-Type: text/plain; charset=utf-8');
echo 'type from request: ' . ($file['type'] ?? '-') . PHP_EOL;
echo 'type from Fileinfo: ' . ($detected ?: '-') . PHP_EOL;
`),
codeBlock(String.raw`
printf '<html>это не фотография</html>' > /tmp/not-an-image.txt
php -S 127.0.0.1:8080
curl -F 'avatar=@/tmp/not-an-image.txt;type=image/jpeg' \
http://127.0.0.1:8080/inspect.php
`),
paragraph('Такой опыт не доказывает, что Fileinfo распознает все форматы без ошибок. Он доказывает более скромную вещь: строка <code>$file["type"]</code> описывает заявление отправителя, а не результат серверной проверки. Этого уже достаточно, чтобы убрать её из условия допуска.'),
heading('Функция, которая возвращает только полезный контракт'),
paragraph('После опыта можно свести проверку к небольшому контракту. Функция ниже не переносит файл и не создаёт запись в базе. Она отвечает только на вопрос, можно ли передать временный файл следующему шагу, и возвращает значение, которое тот шаг действительно использует.'),
codeBlock(String.raw`
<?php
function inspectImageUpload(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Загрузка не завершилась');
}
if (!isset($file['tmp_name'], $file['size']) || (int)$file['size'] > 2097152) {
throw new RuntimeException('Размер файла недопустим');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
throw new RuntimeException('Fileinfo недоступен');
}
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$extensions = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mime) || !isset($extensions[$mime])) {
throw new RuntimeException('Допустимы только JPEG и PNG');
}
return [
'temporaryPath' => $file['tmp_name'],
'mime' => $mime,
'extension' => $extensions[$mime],
'bytes' => (int)$file['size'],
];
}
`),
heading('Почему это не «одна проверка вместо всех»'),
paragraph('Fileinfo отвечает на вопрос о типе, но не о праве пользователя загружать файл, не о свободном месте и не о том, можно ли безопасно разбирать этот формат дополнительной библиотекой. В нашем случае разрешены только две картинки, поэтому белый список короткий. Если продукту нужны PDF, архивы и таблицы, лучше не расширять тот же массив до десятка значений, а сделать отдельные маршруты с отдельными лимитами и правилами выдачи.'),
paragraph('Расширение всё ещё может быть полезным для интерфейса: по нему браузер открывает фильтр выбора, а пользователь понимает, какой файл выбрал. Но серверный ключ и расширение результата лучше строить из решения приложения: Fileinfo вернул <code>image/png</code> — приложение выбирает <code>.png</code>. Так имя не способно незаметно поменять путь или ожидаемый обработчик.'),
heading('Последовательность проверки'),
orderedList([
'Проверить код <code>UPLOAD_ERR_*</code> и остановиться до чтения временного файла при любой ошибке.',
'Проверить размер, потому что допустимый тип не отменяет ограничение на место и время обработки.',
'Определить MIME-тип через Fileinfo и сравнить его с белым списком именно этого сценария.',
'Если нужны размеры, прочитать их после проверки типа и трактовать как требование интерфейса, а не как сертификат безопасности.',
'Передать следующему слою только сгенерированный ключ, серверный MIME-тип и нужные метаданные; клиентское имя оставить за пределами файлового пути.',
]),
heading('Ограничения'),
paragraph('Пример не является антивирусом и не делает опасный формат безопасным. Он также не ограничивает размер всего HTTP-запроса на уровне веб-сервера и PHP-конфигурации. Это нужно проверять отдельно: прикладной лимит защищает логику, а ограничения окружения — сам приём запроса. Если затем файл отдаётся другим пользователям, появляется ещё один самостоятельный вопрос: кто и по какому маршруту его читает.'),
heading('Проверяемые источники'),
sourceList([multipartRfc, phpUploadErrors, phpFileinfo, phpGetImageSize, owaspUpload]),
].join('\n'),
};
const fieldArticle = {
slug: 'editorial-2018-03-field-safe-uploads',
title: 'PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой',
categories: ['PHP', 'Безопасность'],
cover: '/assets/editorial/2018/php-private-download-flow.svg',
excerpt: 'Разбираем контролируемую выдачу документа: путь хранится вне веб-корня, доступ проверяется по записи в базе, а браузер получает содержимое только после авторизации.',
readingMinutes: 9,
contentHtml: [
paragraph('Симптом: личный документ открывается по прямому URL из <code>/uploads</code> без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в <code>/uploads</code>, а ссылка стала чем-то вроде <code>/uploads/ivan-passport.pdf</code>. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.'),
paragraph('Здесь разбираю один вопрос: <strong>как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня?</strong> Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.'),
heading('У файла должны быть две разные сущности'),
paragraph('Пользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением <code>.pdf</code>. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.'),
figure(
'/assets/editorial/2018/php-private-download-flow.svg',
'Схема приватной выдачи: запрос к маршруту проходит авторизацию, запись в базе связывает владельца с ключом, PHP читает файл из закрытого каталога и отправляет ответ.',
'Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся до чтения с диска.',
),
dataTable(
['Слой', 'Что в нём храним', 'Чего в нём нет'],
[
['Таблица <code>documents</code>', '<code>id</code>, <code>owner_id</code>, <code>storage_key</code>, статус', 'Публичного URL и пути, собранного из имени пользователя'],
['Закрытый каталог', 'Файл по ключу, созданному приложением', 'Оригинального имени и логики авторизации'],
['Маршрут <code>/documents/{id}/download</code>', 'Проверку текущего пользователя и HTTP-ответ', 'Свободного параметра <code>path</code> из запроса'],
['Браузер', 'Содержимое файла после успешного ответа', 'Сведений о расположении файла на сервере'],
],
),
heading('Небольшой обработчик PDF'),
paragraph('Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в <code>Content-Disposition</code> тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.'),
codeBlock(String.raw`
<?php
function sendPrivatePdf(PDO $pdo, int $documentId, int $currentUserId): void
{
$query = $pdo->prepare(
'SELECT storage_key
FROM documents
WHERE id = :id AND owner_id = :owner_id AND status = :status'
);
$query->execute([
':id' => $documentId,
':owner_id' => $currentUserId,
':status' => 'ready',
]);
$document = $query->fetch(PDO::FETCH_ASSOC);
if (!$document) {
http_response_code(404);
exit;
}
$key = (string)$document['storage_key'];
if (!preg_match('/\\A[a-f0-9]{32}\\.pdf\\z/', $key)) {
error_log('Некорректный ключ документа ' . $documentId);
http_response_code(404);
exit;
}
$path = '/var/app/private-uploads/' . $key;
if (!is_file($path)) {
error_log('Не найден файл для документа ' . $documentId);
http_response_code(404);
exit;
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="document.pdf"');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
`),
paragraph('SQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым <code>404</code>; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.'),
heading('Как воспроизвести проверку'),
paragraph('На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом <code>ready</code> и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.'),
orderedList([
'Анна запрашивает <code>/documents/42/download</code>: получает <code>200</code>, заголовок <code>Content-Type: application/pdf</code> и байты тестового файла.',
'Борис запрашивает тот же URL: получает <code>404</code>, а тело файла не попадает в ответ.',
'Запрос к предполагаемому пути <code>/uploads/&lt;storage_key&gt;</code> не должен находить файл, потому что каталог не лежит в веб-корне.',
'Удаляем файл на диске при сохранённой записи: получаем <code>404</code> и запись в серверном журнале без абсолютного пути в ответе пользователю.',
'Пробуем передать в URL похожий ID или строку вместо числа: роутер должен отклонить запрос до вызова функции.',
]),
heading('Что будет, если оставить прямую ссылку'),
paragraph('Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.'),
heading('Ограничения этого решения'),
paragraph('У <code>readfile</code> простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.'),
paragraph('Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что <code>header()</code> вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного <code>echo</code> раньше заголовков.'),
heading('Проверяемые источники'),
sourceList([owaspUpload, phpHeader, phpReadfile, contentDispositionRfc]),
].join('\n'),
};
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
if (process.argv[1]?.endsWith('/upgrade-2018-03.mjs')) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node scripts/upgrade-2018-03.mjs --print-revisions\n');
process.exitCode = 1;
}
}