Безопасность загрузки файлов и защита от вредоносных программ

Сегодня мы собираемся завершить серию статей о загрузке файлов в Интернет. Если вы следили за этим, то теперь вы должны быть знакомы с включением загрузки файлов во внешнем интерфейсе и в админке. Мы рассмотрели архитектурные решения, направленные на снижение затрат на то, где мы размещаем наши файлы и улучшение производительности доставки. Поэтому я подумал, что сегодня мы завершим эту серию, рассказав о безопасности, связанной с загрузкой файлов.

Если вы хотите вернуться и пересмотреть какие-либо более ранние блоги в этой серии, вот список того, что мы рассмотрели до сих пор:

  1. Загрузить файлы с HTML
  2. Загрузить файлы с помощью JavaScript
  3. Получать загрузки в Node.js (Nuxt.js)
  4. Оптимизируйте расходы на хранение с помощью Object Storage
  5. Оптимизируйте производительность с помощью CDN
  6. Безопасность загрузки и защита от вредоносных программ

Введение

Каждый раз, когда я обсуждаю тему безопасности, мне нравится консультироваться с экспертами на OWASP.org. Удобно, что у них есть Шпаргалка по загрузке файлов, в которой описаны несколько векторов атак, связанных с загрузкой файлов, и шаги по их смягчению.

Сегодня мы рассмотрим эту шпаргалку и рассмотрим, как внедрить некоторые из их рекомендаций в существующее приложение.

Для справки: у приложения есть внешний интерфейс с формой, которая имеет один входной файл, который загружает этот файл на серверную часть.

Серверная часть работает на основе API обработчика событий Nuxt.js, который получает входящий запрос как объект event, определяет, является ли он запросом multipart/form-data (всегда верно для загрузки файлов), и передает базовый Node. js объект запроса (или IncomingMessage) к этой пользовательской функции с именем parseMultipartNodeRequest.

import formidable from 'formidable';

/* global defineEventHandler, getRequestHeaders, readBody */

/**
 * @see https://nuxt.com/docs/guide/concepts/server-engine
 * @see https://github.com/unjs/h3
 */
export default defineEventHandler(async (event) => {
  let body;
  const headers = getRequestHeaders(event);

  if (headers['content-type']?.includes('multipart/form-data')) {
    body = await parseMultipartNodeRequest(event.node.req);
  } else {
    body = await readBody(event);
  }
  console.log(body);

  return { ok: true };
});

Весь код, на котором мы сегодня сосредоточимся, будет жить внутри этой parseMultipartNodeRequest функции. А поскольку он работает с примитивами Node.js, все, что мы делаем, должно работать в любой среде Node, независимо от того, используете ли вы Nuxt или Next или любой другой фреймворк или библиотеку.

Внутри parseMultipartNodeRequest мы:

  1. Создать новое обещание
  2. Создайте синтаксический анализатор multipart/form-data, используя библиотеку под названием formidable.
  3. Разобрать входящий объект запроса Node.
  4. Парсер записывает файлы в место их хранения
  5. Парсер предоставляет информацию о полях и файлах в запросе

После завершения синтаксического анализа мы разрешаем Promise parseMultipartNodeRequest с полями и файлами.

/**
 * @param {import('http').IncomingMessage} req
 */
function parseMultipartNodeRequest(req) {
  return new Promise((resolve, reject) => {
    const form = formidable({
      multiples: true,
    });
    form.parse(req, (error, fields, files) => {
      if (error) {
        reject(error);
        return;
      }
      resolve({ ...fields, ...files });
    });
  });
}

Это то, с чего мы сегодня начнем, но если вы хотите лучше понять низкоуровневые концепции обработки запросов multipart/form-data в Node, ознакомьтесь с «Обработка загрузки файлов на серверной части в Node.js (и Nuxt). ” Он охватывает темы низкого уровня, такие как фрагменты, потоки и буферы, а затем показывает, как использовать библиотеку вместо того, чтобы писать ее с нуля.

Защита загрузок

Когда наше приложение настроено и запущено, мы можем приступить к реализации некоторых рекомендаций из шпаргалки OWASP.

Проверка расширения

С помощью этого метода мы проверяем расширения имен загружаемых файлов и разрешаем в нашу систему только файлы с разрешенными типами расширений.

К счастью, это довольно легко реализовать с помощью грозного. Когда мы инициализируем библиотеку, мы можем передать параметр конфигурации filter, который должен быть функцией, имеющей доступ к параметру объекта file, который предоставляет некоторые сведения о файле, включая исходное имя файла. Функция должна возвращать логическое значение, которое говорит о том, разрешать ли запись в место хранения или нет.

const form = formidable({
  // other config options
  filter(file) {
    // filter logic here
  }
});

Мы могли бы сравнить file.originalFileName с регулярным выражением, которое проверяет, заканчивается ли строка одним из разрешенных расширений файла. Для любой загрузки, не прошедшей тест, мы можем вернуть false, чтобы указать formidable пропустить этот файл, а для всего остального мы можем вернуть true, чтобы указать formidable записать файл в систему.

const form = formidable({
  // other config options
  filter(file) {
    const originalFilename = file.originalFilename ?? '';
    // Enforce file ends with allowed extension
    const allowedExtensions = /\.(jpe?g|png|gif|avif|webp|svg|txt)$/i;
    if (!allowedExtensions.test(originalFilename)) {
      return false;
    }
    return true;
  }
});

Санитизация имени файла

Очистка имен файлов — это хороший способ защиты от имен файлов, которые могут быть слишком длинными или содержать символы, недопустимые для операционной системы.

Рекомендуется генерировать новое имя файла для любой загрузки. Некоторые параметры могут быть генератором случайных строк, UUID или каким-то хешем.

Еще раз, грозный облегчает нам это, предоставляя параметр конфигурации filename. И снова это должна быть функция, предоставляющая сведения о файле, но на этот раз она ожидает строку.

const form = formidable({
  // other config options
  filename(file) {
    // return some random string
  },
});

На самом деле мы можем пропустить этот шаг, потому что поведение грозного по умолчанию — генерировать случайный хэш для каждой загрузки. Таким образом, мы уже следуем рекомендациям, просто используя настройки по умолчанию.

Ограничения на загрузку и скачивание

Далее мы рассмотрим ограничения на загрузку. Это защищает наше приложение от исчерпания хранилища, ограничивает сумму, которую мы платим за хранилище, и ограничивает объем данных, которые могут быть переданы, если эти файлы будут загружены, что также может повлиять на сумму, которую мы должны заплатить.

Еще раз, мы получаем некоторую базовую защиту, просто используя грозный, потому что он устанавливает значение по умолчанию 200 мегабайт в качестве максимального размера загружаемого файла.

Если мы хотим, мы можем переопределить это значение с помощью специальной опции конфигурации maxFileSize. Например, мы могли бы установить его на 10 мегабайт следующим образом:

const form = formidable({
  // other config options
  maxFileSize: 1024 * 1024 * 10,
});

Выбор правильного значения очень субъективен и зависит от потребностей вашего приложения. Например, приложению, которое принимает видеофайлы высокой четкости, потребуется гораздо более высокий предел, чем тому, которое ожидает только PDF-файлы.

Вы захотите выбрать самое низкое консервативное значение, но не настолько низкое, чтобы это мешало обычным пользователям.

Место хранения файлов

Важно предусмотреть, где будут храниться загруженные файлы. Главной рекомендацией является хранение загруженных файлов в месте, совершенно отличном от того, где работает ваш сервер приложений.

Таким образом, если вредоносное ПО попадет в систему, оно все равно будет помещено в карантин без доступа к работающему приложению. Это может предотвратить доступ к конфиденциальной информации пользователя, переменным среды и т. д.

В одном из моих предыдущих постов «Потоковая загрузка файлов в объектное хранилище S3 и снижение затрат» я показал, как выполнять потоковую загрузку файлов в провайдер объектного хранилища. Так что это не только более экономично, но и более безопасно.

Но если хранение файлов на другом хосте невозможно, следующее лучшее, что мы можем сделать, — это убедиться, что загруженные файлы не попадают в корневую папку на сервере приложений.

Опять же, грозный обрабатывает это по умолчанию. Он хранит любые загруженные файлы во временной папке операционной системы. Это хорошо для безопасности, но если вы хотите получить доступ к этим файлам позже, временная папка, вероятно, не лучшее место для их хранения.

К счастью, есть еще один важный параметр конфигурации, называемый uploadDir, для явного указания места загрузки. Это может быть как относительный путь, так и абсолютный путь.

Так, например, я могу хранить файлы в папке с именем «/uploads» внутри папки моего проекта. Эта папка уже должна существовать, и если я хочу использовать относительный путь, он должен относиться к среде выполнения приложения (обычно к корню проекта). В этом случае я могу установить параметр конфигурации следующим образом:

const form = formidable({
  // other config options
  uploadDir: './uploads',
});

Проверка типа содержимого

Проверка Content-Type важна для обеспечения того, чтобы загруженные файлы соответствовали заданному списку разрешенных MIME-типов. Это похоже на проверку расширения, но важно также проверить MIME-тип файла, потому что злоумышленник может просто переименовать файл, чтобы включить расширение файла, которое находится в нашем разрешенном списке.

Оглядываясь назад на функцию фильтра formidable, мы увидим, что она также предоставляет нам MIME-тип файла. Таким образом, мы могли бы добавить некоторую логику, обеспечивающую соответствие MIME-типа файла нашему списку разрешений.

Мы могли бы изменить нашу старую функцию, чтобы также отфильтровывать любую загрузку, которая не является изображением.

const form = formidable({
  // other config options
  filter(file) {
    const originalFilename = file.originalFilename ?? '';
    // Enforce file ends with allowed extension
    const allowedExtensions = /\.(jpe?g|png|gif|avif|webp|svg|txt)$/i;
    if (!allowedExtensions.test(originalFilename)) {
      return false;
    }
    const mimetype = file.mimetype ?? '';
    // Enforce file uses allowed mimetype
    return Boolean(mimetype && (mimetype.includes('image')));
  }
});

Теперь это было бы здорово в теории, но реальность такова, что formidable фактически генерирует информацию о типе MIME файла на основе расширения файла.

Это делает его не более полезным, чем проверка нашего расширения. Это прискорбно, но это также имеет смысл и, вероятно, останется таковым.

Функция фильтра formidable предназначена для предотвращения записи файлов на диск. Он запускается, когда анализирует загрузки. Но единственный надежный способ узнать MIME-тип файла — это проверить его содержимое. И сделать это можно только после того, как файл уже записан на диск.

Таким образом, технически мы еще не решили эту проблему, но проверка содержимого файла фактически приводит нас к следующей проблеме — проверке содержимого файла.

Антракт

Прежде чем мы углубимся в это, давайте проверим текущую функциональность. Я могу загрузить несколько файлов, в том числе три файла JPEG и один текстовый файл (обратите внимание, что один из файлов JPEG довольно большой).

Когда я загружаю этот список файлов, я получаю неудачный запрос с кодом состояния 500. Консоль сервера сообщает об ошибке из-за превышения максимально допустимого размера файла.

Это хорошо.

Мы предотвратили загрузку в нашу систему файла, размер которого превышает максимальный предельный размер файла (вероятно, нам следует лучше обрабатывать ошибки на бэкэнде, но это задача для другого дня).

Что произойдет, если мы загрузим все эти файлы, кроме большого?

Нет ошибки.

И заглянув в папку «uploads», мы увидим, что несмотря на загрузку трех файлов, сохранились только два. Файл .txt не прошел наш фильтр расширения файла.

Мы также заметим, что имена двух сохраненных файлов представляют собой случайные хеш-значения. Еще раз, это благодаря грозному поведению по умолчанию.

Теперь только одна проблема. Одна из этих двух успешных загрузок произошла из выбранного мной файла «bad-dog.jpeg». На самом деле этот файл был копией «bad-dog.txt», который я переименовал. И ЭТОТ файл на самом деле содержит вредоносное ПО 😱😱😱

Мы можем доказать это, запустив один из самых популярных антивирусных инструментов Linux в папке загрузок ClamScan. Да, ClamScan — это реальная вещь. Да, это его настоящее имя. Нет, я не знаю, почему они так его назвали. Да, я знаю, как это звучит.

(Примечание: файл, который я использовал, был создан для тестирования вредоносного программного обеспечения. Так что он безвреден, но он предназначен для запуска сканеров вредоносных программ. ИТ-отдел только для того, чтобы получить копию. Так что вам лучше чему-нибудь научиться.)

Хорошо, теперь давайте поговорим о проверке содержимого файла.

Проверка содержимого файла

Проверка содержимого файла — это причудливый способ сказать «сканировать файл на наличие вредоносных программ», и это один из наиболее важных шагов безопасности, которые вы можете предпринять при приеме загрузки файлов.

Выше мы использовали ClamScan, так что теперь вы можете подумать: «Ага, почему бы мне просто не сканировать файлы, как их анализирует грозный?»

Подобно проверке MIME-типа, сканирование вредоносных программ может происходить только после того, как файл уже записан на диск. Кроме того, сканирование содержимого файла может занять много времени. Гораздо дольше, чем это уместно в цикле запрос-ответ. Вы бы не хотели, чтобы пользователь ждал так долго.

Таким образом, у нас есть две потенциальные проблемы:

  • К тому времени, когда мы можем начать сканирование файла на наличие вредоносных программ, он уже находится на нашем сервере.
  • Мы не можем дождаться завершения сканирования, прежде чем отвечать на запросы пользователя на загрузку.

Облом…

Архитектура сканирования вредоносных программ

Запуск сканирования на наличие вредоносных программ при каждом отдельном запросе на загрузку, вероятно, не вариант, но есть решения. Помните, что цель состоит в том, чтобы защитить наше приложение от вредоносных загрузок, а также защитить наших пользователей от вредоносных загрузок.

Вместо того, чтобы сканировать загрузки во время цикла запрос-ответ, мы могли бы принимать все загруженные файлы, хранить их в безопасном месте и добавлять в базу данных запись, содержащую метаданные файла, место хранения и флаг, чтобы отслеживать, был ли файл отсканировано.

Затем мы можем запланировать фоновый процесс, который находит и сканирует все записи в базе данных на наличие непросканированных файлов. Если он обнаружит какое-либо вредоносное ПО, он может удалить его, поместить в карантин и/или уведомить нас. Для всех чистых файлов он может обновить соответствующие записи базы данных, чтобы пометить их как отсканированные.

Наконец, есть соображения, которые нужно сделать для передней части. Скорее всего, мы захотим показать любые ранее загруженные файлы, но мы должны быть осторожны с предоставлением доступа к потенциально опасным файлам. Вот несколько разных вариантов:

  • После загрузки покажите информацию о файле только тому пользователю, который его загрузил, сообщив ему, что он не будет доступен другим пользователям до тех пор, пока он не будет отсканирован. Вы даже можете отправить им электронное письмо, когда все будет готово.
  • После загрузки покажите файл каждому пользователю, но не предоставляйте возможность загрузки файла до тех пор, пока он не будет отсканирован. Включите некоторые сообщения, чтобы сообщить пользователям, что файл ожидает сканирования, но они все еще могут видеть метаданные файла.

Какой вариант подходит именно вам, зависит от варианта использования вашего приложения. И, конечно же, в этих примерах предполагается, что ваше приложение уже имеет базу данных и возможность планировать фоновые задачи.

Здесь также стоит упомянуть, что одна из рекомендаций OWASP заключалась в том, чтобы ограничить возможности загрузки файлов аутентифицированными пользователями. Это упрощает отслеживание и предотвращение злоупотреблений.

К сожалению, базы данных, учетные записи пользователей и фоновые задачи требуют больше времени, чем у меня есть в сегодняшней статье, но я надеюсь, что эти концепции дадут вам больше идей о том, как вы можете улучшить свои методы безопасности загрузки.

Блокировать вредоносное ПО на периферии

Прежде чем мы закончим сегодня, есть еще одна вещь, о которой я хочу упомянуть. Если вы являетесь клиентом Akamai, у вас фактически есть доступ к функции защиты от вредоносных программ в составе продуктов брандмауэра веб-приложений. Мне пришлось немного поиграть с ним, и я хочу показать его, потому что это очень круто.

У меня запущено и работает приложение на uploader.austingil.com. Он уже интегрирован с CDN Akamai’s Ion, поэтому его было легко настроить с помощью конфигурации безопасности, включающей IP/Geo Firewall, защиту от отказа в обслуживании, WAF и защиту от вредоносных программ.

Я настроил политику защиты от вредоносных программ, чтобы просто отклонять любой запрос, содержащий вредоносное ПО или несоответствие типа контента.

Теперь, если я зайду в свое приложение и попытаюсь загрузить файл, содержащий известное вредоносное ПО, я почти сразу же увижу, что ответ отклонен с кодом состояния 403.

Чтобы было ясно, это логика, которую я на самом деле не прописывал в своем приложении. Это происходит благодаря защите Akamai от вредоносных программ, и мне очень нравится этот продукт по ряду причин.

  • Его удобно и легко настраивать и изменять из пользовательского интерфейса Akamai.
  • Мне нравится, что мне не нужно изменять приложение для интеграции продукта.
  • Он хорошо справляется со своей задачей, и мне не нужно заниматься его обслуживанием.
  • Наконец, что не менее важно, файлы сканируются на пограничных серверах Akamai, что означает, что это не только быстрее, но и предотвращает доступ заблокированных вредоносных программ к моим серверам. Это, пожалуй, моя любимая функция.

Из-за ограничений по времени и ресурсам я думаю, что защита от вредоносных программ может сканировать только файлы до определенного размера, поэтому она не будет работать для всего, но это отличное дополнение для блокировки некоторых файлов даже от попадания в вашу систему.

Заключительные мысли

Важно помнить, что не существует единого решения, когда речь идет о безопасности. У каждого из рассмотренных нами шагов есть свои плюсы и минусы, и, как правило, рекомендуется добавить несколько уровней безопасности в ваше приложение.

Хорошо, на этом мы завершаем серию статей о загрузке файлов в Интернет. Если вы еще этого не сделали, подумайте о том, чтобы прочитать некоторые другие статьи.

  1. Загрузить файлы с HTML
  2. Загрузить файлы с помощью JavaScript
  3. Получать загрузки в Node.js (Nuxt.js)
  4. Оптимизируйте расходы на хранение с помощью Object Storage
  5. Оптимизируйте производительность с помощью CDN
  6. Безопасность загрузки и защита от вредоносных программ

Пожалуйста, дайте мне знать, если вы нашли это полезным, или если у вас есть идеи о других сериях, которые вы хотели бы, чтобы я осветил. Я хотел бы услышать от вас.

Большое спасибо за чтение. Если вам понравилась эта статья и вы хотите поддержать меня, лучший способ сделать это — поделиться ею, подписаться на мою рассылку и подписаться на меня в Twitter.

Первоначально опубликовано на austingil.com.

Повышение уровня кодирования

Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:

  • 👏 Хлопайте за историю и подписывайтесь на автора 👉
  • 📰 Смотрите больше контента в публикации Level Up Coding
  • 💰 Бесплатный курс собеседования по программированию ⇒ Просмотреть курс
  • 🔔 Подписывайтесь на нас: Twitter | ЛинкедИн | "Новостная рассылка"

🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите прекрасную работу