Обработка загрузки изображений на сервере
Безопасная загрузка изображений в Node: проверка сигнатуры в памяти, лимиты размера, перекодирование через sharp и безопасная отдача файлов.
Серверная валидация загружаемых файлов начинается в момент, когда приходит multipart-запрос, и с этого момента доверять стоит только байтам. Заголовок Content-Type и имя файла приходят из браузера, поэтому относитесь к ним как к утверждениям, а не как к фактам. Если вы попали сюда из клиентских материалов про создание миниатюр изображений перед загрузкой и конвертацию изображений в Base64 с помощью Canvas, то это принимающая сторона того же конвейера.
Две половины конвейера решают разные задачи. Изменение размера и сжатие на стороне браузера — это любезность по отношению к пользователю; серверная валидация — это то, что останавливает тело запроса на 2 ГБ или скрипт, замаскированный расширением .jpg. В этой статье разбираются пять механизмов контроля, необходимых эндпоинту для изображений на Node: проверка сигнатур, многоуровневые ограничения размера, генерация имён файлов, повторное кодирование и безопасная отдача.
Ключевые выводы
- Заголовок Content-Type и имя файла в multipart-загрузке задаются клиентом, поэтому сервер, проверяющий любой из них, валидирует утверждение атакующего о файле, а не сам файл.
- Проверка сигнатуры должна читать первые байты файла в памяти, до того как что-либо попадёт на диск, и сравнивать их с форматами, которые принимает именно этот эндпоинт.
- Ограничения размера должны стоять в первую очередь на прокси, во вторую — в опции
limitsу Multer, и в последнюю — в коде приложения; значениеfileSizeв Multer по умолчанию равно Infinity, поэтому неустановленный лимит означает отсутствие лимита. - Повторное кодирование через sharp — это то, что делает подделку сигнатуры бессмысленной: на выходе получается новый файл, и метаданные EXIF, включая GPS-координаты из фотографий со смартфона, его не переживают.
- Отдавайте сохранённые изображения с Content-Type из вашей собственной записи о валидации, заголовком
X-Content-Type-Options: nosniffи путём хранения за пределами веб-корня.
Почему проверки MIME-типа и расширения не работают?
Проверка file.mimetype или расширения имени файла не валидирует файл, поскольку оба значения предоставляет клиент. Две предыдущие статьи в этом блоге давали именно такой совет: Multer NPM: File Upload in Node.js показывает fileFilter, который принимает любой файл, чей mimetype есть в списке разрешённых, а Safe User Input Handling in Node.js советует читателям валидировать MIME-типы на уровне парсера. Обе проверки стоит оставить как дешёвый ранний отсев, но ни одна из них не является средством безопасности.
Подделка занимает одну строку:
curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload
Multer копирует это значение type прямиком в req.file.mimetype. Ваш фильтр видит image/jpeg; а в теле — shell-скрипт. OWASP File Upload Cheat Sheet прямо говорит, что валидация должна опираться на содержимое файла, а не на метаданные, предоставленные клиентом.
Валидация загрузки начинается с сигнатуры файла
Читайте первые байты буфера в памяти, до того как что-либо будет записано на диск, и сравнивайте их с форматами, которые принимает именно этот эндпоинт. Важны обе половины этого предложения. В туториалах обычно валидируют уже после того, как файл оказался в директории uploads, а значит, чрезмерно большая или враждебная нагрузка успела нанести ущерб до запуска проверки. И там же обычно пропускают файл, если он соответствует любой известной сигнатуре, так что PDF спокойно проходит через эндпоинт для аватаров. Эндпоинт аватаров, распознающий PDF, содержит ошибку валидации, а не фичу.
Сами сигнатуры: JPEG начинается с FF D8 FF, PNG начинается с полной 8-байтовой последовательности, определённой в спецификации PNG, а WebP требует RIFF со смещения 0 плюс WEBP в байтах с 8 по 11, согласно спецификации контейнера WebP. Байты с 4 по 7 — это размер RIFF-чанка, поэтому проверка WebP должна их пропускать, и поэтому наивное чтение 4 байт некорректно обрабатывает ни PNG, ни WebP.
const SIGNATURES = {
"image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
"image/png": (b) =>
b.length >= 8 &&
b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
"image/webp": (b) =>
b.length >= 12 &&
b.subarray(0, 4).toString("ascii") === "RIFF" &&
b.subarray(8, 12).toString("ascii") === "WEBP",
};
// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}
Одна оговорка: сигнатуры можно подделать, дописав нужные байты в начало любой полезной нагрузки, — это дело секунд. Пакет file-type сам говорит об этом в своём README, где совпадение magic numbers описывается как подсказка о формате, а не как доказательство. Считайте проверку сигнатуры быстрым и дешёвым фильтром. Граница безопасности — двумя разделами ниже.
Где должны находиться ограничения размера загрузки?
Ограничения размера должны стоять в первую очередь на прокси, во вторую — на уровне фреймворка, и в последнюю — в коде приложения, потому что проверка, выполняемая после буферизации тела запроса, отклоняет загрузку только тогда, когда вся полезная нагрузка уже стоила вам памяти и трафика. В nginx директива client_max_body_size по умолчанию равна 1 МБ и отвечает на слишком большие запросы кодом 413 ещё до того, как их увидит ваш процесс:
client_max_body_size 5m;
Уровень фреймворка — это Multer (2.x), где limits.fileSize по умолчанию равен Infinity, так что неустановленный лимит означает отсутствие лимита:
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
При достижении порога Multer выбрасывает ошибку LIMIT_FILE_SIZE. В Fastify пакет @fastify/multipart занимает противоположную позицию и задаёт fileSize по умолчанию равным 1 МиБ, так что безопасное поведение здесь — это поведение по умолчанию, а не то, что нужно включать явно. Сессионные записи (session replays) процессов загрузки делают цену пропуска прокси-уровня видимой как UX-дефект: прогресс-бар доходит до 100 процентов, и только потом появляется отказ, потому что вся полезная нагрузка должна была прийти, прежде чем сработала проверка на уровне приложения.
Отбрасывайте имя файла, присланное клиентом
Никогда не позволяйте клиентскому имени файла касаться вашей файловой системы. Генерируйте своё собственное с помощью crypto.randomUUID() плюс расширение, которое определила ваша собственная валидация:
const storedName = `${crypto.randomUUID()}.jpg`;
Обоснование — от последовательностей обхода каталогов до того, почему path.normalize не является защитой, — разобрано в статье Preventing Path Traversal Attacks in Node.js; подход с генерируемыми именами делает весь этот класс атак недостижимым.
Перекодируйте изображение, не храните исходные байты
Повторное кодирование — это тот механизм, который делает подделку сигнатуры несущественной. sharp (0.35.x) декодирует пиксели и записывает совершенно новый файл, поэтому всё, что было дописано в начало, в конец или спрятано внутри оригинала, не выживает. Заодно это закрывает утечку приватности: фотографии со смартфонов регулярно несут GPS-координаты в EXIF-данных, и хранение исходных байтов означает повторную публикацию местоположения вашего пользователя. Документация sharp по выводу чётко описывает поведение по умолчанию: ничего из метаданных входного файла не попадает в выходной, пока вы не запросите это обратно через keepExif() или withMetadata(). Флаг ориентации уходит вместе со всем остальным, поэтому сначала выполняйте авто-поворот через .autoOrient(), иначе фотографии со смартфона получатся повёрнутыми набок:
let clean;
try {
clean = await sharp(req.file.buffer)
.autoOrient() // apply EXIF orientation before metadata is stripped
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}
Буфер, прошедший проверку сигнатуры, но не поддающийся декодированию, лгал о своём формате. Это и есть работающая проверка.
Отдавайте то, что вы провалидировали, а не то, что получили
При отдаче сохранённых изображений устанавливайте Content-Type из вашей записи о валидации, никогда — из того, что прислал клиент, добавляйте X-Content-Type-Options: nosniff, чтобы браузер не пытался угадать тип самостоятельно, и храните файлы за пределами веб-корня, чтобы ничто загруженное никогда не могло быть напрямую исполнено или отрендерено:
app.get("/images/:id", async (req, res) => {
const record = await getImageRecord(req.params.id); // contentType saved at validation time
if (!record) return res.sendStatus(404);
res.setHeader("Content-Type", record.contentType);
res.setHeader("X-Content-Type-Options", "nosniff");
res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});
Несколько смежных тем, каждая — одной строкой:
- Антивирусное сканирование (ClamAV или облачный аналог) имеет смысл, когда вы принимаете произвольные документы, а не перекодированные изображения.
- Если вы всё-таки принимаете архивы, проверяйте размер после распаковки до извлечения; zip-бомбы — это маленькие файлы, которые разворачиваются до колоссальных размеров.
- SVG — это XML-документ, способный нести скрипт, поэтому полностью исключите его из эндпоинтов для изображений.
- Архитектуры с presigned URL загружают файлы напрямую в объектное хранилище, что переносит весь этот конвейер в этап постобработки после загрузки, а не устраняет его.
Подведём итоги
Пять механизмов контроля образуют единый конвейер: отклоняйте по размеру на прокси, проверяйте сигнатуру в памяти по списку разрешённых форматов этого эндпоинта, перекодируйте через sharp, сохраняйте под сгенерированным вами именем и отдавайте с заголовками, которые контролируете вы.
| Механизм | Где выполняется | Что предотвращает |
|---|---|---|
| Ограничение размера | Сначала прокси, затем limits в Multer, в последнюю очередь код приложения | Слишком большие тела запросов, съедающие память и трафик |
| Проверка сигнатуры | В памяти, до того как что-либо попадёт на диск | Байты, не соответствующие списку разрешённых форматов этого эндпоинта |
| Перекодирование через sharp | После валидации, до сохранения | Поддельные сигнатуры, скрытые полезные нагрузки, GPS-данные EXIF |
| Сгенерированное имя файла | В момент сохранения | Обход каталогов через клиентское имя файла |
| Валидированные заголовки при отдаче | При каждом чтении | MIME-sniffing и исполнение из веб-корня |
Проверка сигнатуры фильтрует дёшево; перекодирование — это граница, которая держится даже тогда, когда сигнатура была подделана. Начните с проверки собственной конфигурации Multer: если limits.fileSize не задан, этот эндпоинт в данный момент принимает файлы неограниченного размера.
Часто задаваемые вопросы
Заменяет ли npm-пакет file-type написанную вручную проверку сигнатуры?
Он заменяет сравнение байтов, но не модель безопасности. file-type читает те же самые magic numbers, и его README прямо говорит, что это вам даёт: совпадение — это подсказка, и оно не подтверждает ни того, что файл действительно относится к этому типу, ни того, что он корректно сформирован. Поверх всё равно нужен список разрешённых форматов для конкретного эндпоинта, поскольку пакет распознаёт сотни форматов, а настоящей границей остаётся перекодирование. Учтите, что пакет доступен только в формате ESM, поэтому CommonJS-проектам понадобится динамический import или обходной приём через load-esm.
Останавливает ли лимит fileSize в Multer отправку клиентом остатка файла?
Не гарантированно. При достижении limits.fileSize Multer прекращает буферизацию и выбрасывает ошибку LIMIT_FILE_SIZE, что защищает ваш процесс от неограниченного расхода памяти, но ничто в документации не гарантирует отмену сетевой передачи, и ошибка может появиться только после того, как придут все байты. Реально трафик защищает ограничение на уровне прокси, например nginx client_max_body_size, — именно поэтому лимит в первую очередь должен стоять на прокси.
Как валидировать загрузки, когда клиенты используют presigned URL для прямой загрузки в объектное хранилище?
Валидация переносится на этап после загрузки, а не исчезает. Клиент загружает файл в карантинный бакет или префикс, из которого ничего не отдаётся, затем фоновый воркер или функция, срабатывающая по событию хранилища, скачивает объект, выполняет ту же проверку сигнатуры и перекодирование через sharp и записывает очищенный результат в публичное расположение под сгенерированным именем. Объекты, не прошедшие валидацию, удаляются, а карантинное расположение никогда не доступно браузерам.
Меняет ли перекодирование через sharp цвета изображения?
Может — для изображений с широким цветовым охватом. Без дополнительных настроек sharp выдаёт результат в sRGB без прикреплённого профиля, поскольку удаление метаданных уносит с собой и встроенный ICC-профиль, так что фотографии, созданные в Display P3 или Adobe RGB, могут слегка сместиться по цвету. Чтобы сохранить цвет, не возвращая EXIF-данные, вызовите keepIccProfile() перед кодированием. keepMetadata() тоже сохраняет профиль, но возвращает всё подряд, включая GPS-координаты, от публикации которых удаление метаданных защищало ваших пользователей.