От загрузки до URL: безопасное хранение и обслуживание файлов пользователей в Express
Узнайте, где приложения Express должны хранить загруженные файлы, как express.static сопоставляет папки с URL-адресами, и какие меры защиты предотвращают превращение загруженных пользователем файлов в угрозу безопасности.
Источники: Storing Uploaded Files and Serving Them in Express
Пользователь выбирает фото профиля, нажимает «Загрузить», и спустя секунду оно появляется на его странице. За этим простым действием стоят три важных дизайнерских решения: где физически хранится файл, как сервер находит его снова и как браузер отображает его. В этом руководстве рассматриваются все три аспекта для приложения Express — от выбора места хранения до предоставления файлов по URL, а также приводятся меры предосторожности, которые помогают избежать использования загруженных файлов в качестве поверхности для атак.
Хранение загруженных файлов на диске
Прежде чем файл сможет быть снова предоставлен пользователю, его необходимо сохранить где-то. В самой простой конфигурации это папка в собственной файловой системе сервера, обычно называемая uploads и располагаемая в корне проекта рядом с исходным кодом, а не внутри него:
project-root/
uploads/
avatars/
user-42.png
user-91.png
documents/
invoice-108.pdf
src/
app.js
Группировка файлов по назначению, такой как avatars/ и documents/, быстро приносит пользу. Она позволяет сохранять структуру каталогов управляемой по мере увеличения их количества, облегчает применение правил очистки и резервного копирования в зависимости от категории, а также, как вы скоро увидите, генерирует URL-адреса, которые легко предсказуемы. Одна плоская папка, содержащая все виды файлов, уже на раннем этапе становится сложной для управления, ещё до того, как возникнут проблемы с производительностью.
В большинстве приложений база данных хранит лишь ссылку на файл, например относительный путь avatars/user-42.png, а не сам файл и не полный URL. Хранение относительного пути позволяет позже изменить домен или перейти на другую систему хранения без необходимости переписывать каждую строку.
Локальный диск или внешняя система хранения объектов
Хранение файлов на сервере приложения
Запись файлов непосредственно на диск сервера, как показано выше, не требует дополнительной инфраструктуры. Этот подход хорошо подходит для локальной разработки, прототипов, внутренних инструментов и приложений, объем загрузки которых невелик и предсказуем.
Перенос файлов в сервис хранения
В производственных системах загрузки чаще передаются в специализированный хранилище объектов, такое как Amazon S3, Google Cloud Storage или аналогичные сервисы, тем самым избегая использования диска сервера приложения.
Почему выбор важен
- Диски могут быть небольшими и временными. Многие платформы хостинга пересоздают или заменяют инстансы при каждой развертке, поэтому файл, существующий только на локальном диске, может исчезнуть при следующей развертке.
Всё это не делает локальное хранилище неправильным. Для учебных проектов и действительно небольших приложений это разумный первый шаг, а приведённые ниже рекомендации (организованные пути, предсказуемые URL-адреса, тщательная валидация) останутся применимыми при переходе на внешнее хранилище. Размещение кода хранения в небольшом модуле с функциями вроде save и getUrl значительно облегчает такую миграцию.
Использование express.static для обслуживания папки
После сохранения файла браузеру всё равно нужен способ его получить. Для именно этой цели в Express предусмотрен мидлвэрд для обработки статических файлов:
const express = require("express");
const app = express();
app.use("/uploads", express.static("uploads"));
Эта строка подключает каталог uploads под URL-префиксом /uploads. Для любого запроса, путь к которому начинается с этого префикса, Express ищет файл в соответствующем месте внутри папки, и если он существует, передаёт его обратно с соответствующим типом содержимого и заголовками кэширования. Не требуется писать отдельный обработчик для каждого файла.
Это отличается по принципу работы от обычного маршрута API. Вместо выполнения логики загрузки данных и формирования ответа вы публикуете целую папку, которая определяется исключительно по пути. Из этого следуют два практических замечания:
- Аргумент с папкой определяется относительно каталога, из которого запущен процесс Node, а не относительно файла с кодом. Использование абсолютного пути, например, сформированного с помощью
path.join(__dirname, „..“, „uploads“), предотвращает неожиданности при запуске приложения из другого рабочего каталога. - Все, что находится в этой папке, становится общедоступным. Монтируйте только тот каталог, который предназначен для публикации.
Как сохранённый файл превращается в URL
После настройки статической обработки пути на диске и пути в URL полностью совпадают:
File on disk: uploads/avatars/user-42.png
Accessible at: https://yourapp.com/uploads/avatars/user-42.png
Благодаря этому однозначному соответствию фронтенд может напрямую указывать на файл, например в теге изображения, без необходимости использования специального API-эндпоинта для загрузки данных:
<img src="https://yourapp.com/uploads/avatars/user-42.png" alt="User avatar" />
Именно здесь структура папок, описанная в первом разделе, находит свое применение. Любой формат хранения на диске полностью соответствует тому, что клиенты видят в URL-адресах, поэтому аккуратная и последовательная структура обеспечивает четкие и единообразные ссылки. Кроме того, это позволяет формировать URL на основе относительного пути, хранящегося в базе данных, что позволяет сохранять домен и префикс в одном значении конфигурации.
Меры безопасности при загрузке пользователем файлов
Загружаемые файлы часто становятся мишенью для злоупотреблений, поэтому утверждение «загрузка работает» является лишь отправной точкой. Каждый из следующих шагов устраняет конкретную уязвимость.
Проверьте, что на самом деле является файлом
Расширение имени файла — это просто текст, выбранный пользователем, и оно ничего не доказывает. Необходимо проверять реальный тип содержимого, желательно путем чтения первых байтов файла, а не полагаясь на указанный тип MIME, и принимать только то, что требуется функционалу. Например, конечная точка для аватаров должна принимать только изображения. Форматы вроде SVG и HTML требуют особой осторожности, поскольку браузеры могут выполнять в них встроенные скрипты.
Ограничение размера
Установите максимальный размер для каждой загрузки. Без ограничений один чрезмерно большой запрос, будь он случайным или умышленным, может заполнить диск или перегрузить пропускную способность. Необходимо вводить ограничение при обработке запроса, чтобы сервер прекратил чтение раньше времени, а не сначала принял весь объем данных.
Создавайте собственные имена файлов
Никогда не создавайте файл под именем, указанным клиентом. В этом имени могут быть странные символы или последовательности для преодоления структуры путей, такие как ../../config.js, предназначенные для выхода за пределы каталога загрузки. Создайте на сервере новое имя, например, случайный идентификатор вместе с проверенным расширением. Как побочный эффект это также предотвращает перезапись файлов двумя пользователями, загружающими один и тот же файл photo.jpg.
Изолируйте каталог загрузки
Храните загруженные файлы в каталоге, четко отделенном от исходного кода и конфигурации, и убедитесь, что статический мидлвэр открывает доступ только к этому каталогу. Ошибочная настройка подключения к корню проекта приведет к публикации файлов среды и исходного кода для всех, кто сможет угадать путь.
Подумайте о том, какой источник обслуживает файлы
Предоставление контента, загруженного пользователем, с того же домена, что и основное приложение, может привести к проблемам вроде кросс-сайтового скриптинга, поскольку злонамеренный файл будет выполняться с исходным адресом вашего сайта. По этой причине многие производственные системы используют загрузки из внешней памяти или отдельного поддомена. Отправка заголовка X-Content-Type-Options: nosniff и, для загрузок, которые не должны отображаться внутри текста, заголовка Content-Disposition: attachment, дополнительно снижает риски.
Часто задаваемые вопросы
Должен ли каждый проект использовать S3 или аналогичный сервис?
Не обязательно. Для небольших или учебных проектов локальный диск является более простым решением и полностью подходит. Внешняя память становится важной, когда вы развертываете приложение на платформе с временной файловой системой или запускаете несколько инстансов, которые должны иметь доступ к одинаковым файлам.
Требуется ли для express.static дополнительный пакет?
Нет, это встроено в Express. Для парсинга загружаемых данных из формы с несколькими частями обычно действительно требуется библиотека вроде Multer, но для обслуживания уже сохранённых файлов она не нужна.
Допустимо ли сохранять исходное имя файла?
Более безопасно этого не делать. Имя, сгенерированное сервером, исключает странные символы, коллизии одинаковых имён и угрозы, связанные с манипуляциями имёнами файлов. Если пользователям нужно видеть исходное имя, храните его в качестве метаданных в базе данных.
Можно ли ограничить доступ к загруженным файлам?
Да, но не с помощью только express.static, поскольку он передаёт файл любому, кто знает его URL. Для приватных файлов требуется специальный маршрут, который проверяет авторизацию и разрешения перед тем, как начать стримирование файла, или же, при использовании внешнего хранилища, краткосрочные ссылки, создаваемые только для разрешённых пользователей.
Заключение
Надежная функция загрузки позволяет последовательно ответить на четыре вопроса. Где хранится файл: на диске сервера из-за простоты или в хранилище объектов, поскольку он сохраняется при обновлениях и позволяет масштабирование? Как приложение будет находить его позже: с помощью структурированной иерархии папок и относительного пути, сохраняемого в базе данных? Как браузер скачает его: через express.static, привязанного к четкому URL-префиксу, или через защищенный маршрут для приватных файлов? И что обеспечивает его безопасность: проверка типов, ограничения по размеру, имена, генерируемые сервером, и узко определенное, желательно отдельное место хранения. Решив эти вопросы, остальная часть реализации становится довольно простой.