Главная / Статьи

Безопасность API: JWT и OAuth, проверка данных, ограничение частоты запросов, мониторинг.

Практическое реализование безопасности API: аутентификация и авторизация с использованием JWT и OAuth, шифрование, проверка входных данных, ограничение частоты запросов и ведение журналов для выявления атак.

Издатель: REACTAPP.TOP22 мин чтения5067 слов

Источники: What Are the Best Practices for API Security? A Complete Guide to Securing Modern APIs

Каждый API, который вы предоставляете, является входом в вашу систему, и злоумышленники исследуют эти входы гораздо более систематично, чем это делают большинство команд. Безопасность, добавляемая после запуска, как правило, оставляет уязвимости: маршрут без защиты, запрос, сформированный на основе необработанных данных, точка входа для входа, которая с радостью принимает миллион попыток входа. В этом руководстве рассматриваются стандарты, которые стоит знать, наиболее часто встречающиеся угрозы, а также шесть конкретных уровней защиты, реализованных в Node.js с использованием Express, чтобы вы могли пройти аудит существующего API или создать новый с защитой с самого первого дня.

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

Почему безопасность API требует особого внимания

Современные продукты всё чаще создаются с использованием API. Вместо того чтобы разрабатывать каждую функцию самостоятельно, команды интегрируют сервисы оплаты, идентификации, обмена сообщениями и обработки данных через чётко определённые интерфейсы, и многие компании теперь выпускают продукты, ориентированные прежде всего на API. По данным одного из исследований рынка, объём экономики API к 2026 году составит примерно 20 миллиардов долларов (краткое содержание отчёта); независимо от точной цифры, зависимость от API реальна и продолжает расти.

Эта зависимость имеет двусторонний характер. API предоставляет законным клиентам готовые, повторно используемые функции, но в то же время даёт злоумышленникам документированный, удобный для автоматизации путь к взлому. Исследования отрасли регулярно связывают большое количество инцидентов утечек данных с API; в отчёте компании Traceable за 2023 год 74% утечек данных приписаны именно им.

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

Стандарты, которые стоит знать, от OWASP до OAuth и JWT

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

  • OWASP API Security Top 10: ранжированный список наиболее критичных рисков для API, ведомый проектом Open Web Application Security Project. В нем рассматриваются такие проблемы, как нарушения авторизации на уровне объектов, подделка запросов с серверной стороны и неограниченное потребление ресурсов; это лучшая отправная точка для анализа угроз.
  • OAuth 2.0 и 2.1: фреймворк делегированной авторизации. Клиент получает токен доступа с определенными диапазонами полномочий и использует его для действий от имени пользователя, не имея доступа к его паролю; токены обновления позволяют клиенту получать новые токены доступа без повторной просьбы к пользователю.
  • OpenID Connect (OIDC): слой идентификации, построенный на основе OAuth. Он стандартизирует токен идентификации и способы его верификации клиентами, что обеспечивает взаимодействие между сервисами для однократной аутентификации и получения информации о профиле от поставщиков идентификации.
  • JSON Web Tokens (JWTs): компактный формат подписанного токена. В отличие от нечитаемого куки сессии, JWT содержит собственные данные (идентификатор пользователя, роль, срок действия) прямо внутри самого токена, а сервер проверяет подпись вместо поиска информации о сессии. Следует помнить, что стандартный подписанный JWT кодируется, а не шифруется, поэтому любой, у кого он есть, может прочитать его содержимое.
  • Transport Layer Security (TLS): криптографический протокол, лежащий в основе HTTPS. Он обеспечивает шифрование данных во время передачи, аутентификацию участников обмена и проверку целостности для выявления возможных изменений.
  • Zero Trust: архитектурный подход, согласно которому каждый пользователь и сервис считаются ненадежными до момента их верификации. Он основан на прямой проверке, принципе минимальных привилегий и предположении о том, что нарушение безопасности уже произошло где-то.
  • Рамка кибербезопасности NIST: рекомендации, хорошо применимые к архитектурам облаков и микросервисов, с акцентом на аутентификацию, авторизацию и защиту данных.
  • Профили API финансового уровня (FAPI): усиленные профили OAuth и OIDC для критически важных сфер, таких как банковское дело, финтех и регулируемые данные. Они требуют строгой аутентификации клиентов, более строгой обработки запросов и ответов, а также токенов с ограничениями по отправителю, которые становятся бесполезными при краже, что помогает предотвратить мошенничество и улучшить взаимодействие.
  • Эти инструменты обеспечивают прочную основу, хотя и не являются исчерпывающим списком.

    Угрозы, от которых вы защищаетесь

    Наиболее часто встречающиеся уязвимости в реальных API включают:

    1. Несоблюдение принципов аутентификации: слабая или отсутствующая проверка личности и ненадлежащая обработка сессий позволяют злоумышленнику украсть куки или токены и использовать их повторно для доступа к вашим сервисам.
    2. Несоблюдение принципов авторизации на уровне объектов (BOLA): API проверяет, вошел ли пользователь в систему, но не проверяет, разрешено ли ему работать с конкретной записью; поэтому изменение ID в URL может привести к раскрытию данных других пользователей или внутренних процессов.
    3. Вставка SQL-запросов: введенные злоумышленником данные объединяются с запросом и выполняются базой данных. Почти каждый клиент базы данных предоставляет механизм параметров для безопасной передачи значений.
    4. Вставка команд: ненадежные данные из запроса попадают в оболочку системы или процесс обработки команд, что позволяет злоумышленнику выполнять произвольные команды с привилегиями процесса вашего сервера.
  • Межсайтовое скриптинговое взломание (XSS): внедренный скрипт выполняется в браузере жертвы в контексте вашего приложения, что позволяет злоумышленнику действовать от имени этого пользователя и читать данные, которые обычно защищены правилом одного источника.
  • Неправильная настройка безопасности: утечка ключей API, обнаружение переменных среды, слишком либеральные стандартные настройки. Взломанные учетные данные могут быть использованы для вызова вашего API или для формирования счетов за платные сервисы сторонних поставщиков.
  • Чрезмерная или небезопасная передача данных: обработчик возвращает всё объектное представление базы данных вместо тех полей, которые нужны клиенту. Даже если интерфейс никогда их не отображает, данные остаются в кэше, локальной памяти и вкладке сети браузера.
  • Отказ в обслуживании (включая дистрибутированные атаки): наводнение запросами исчерпывает ресурсы сервера или полностью выводит API из строя.
  • Зависимости от сторонних сервисов: ваш API также является клиентом других API и пакетов. Каждый из них увеличивает вашу уязвимость, и любое нарушение безопасности или сбой в них становятся вашей проблемой.
  • Для каждого из этих случаев существует соответствующая практика программирования. Остальная часть данного руководства рассматривает их в шести уровнях, используя JavaScript и Express на протяжении всего текста.

    1. Аутентификация с использованием паролей и JWT: подтверждение личности звонящего

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

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

    В Express библиотека Passport поддерживает оба подхода с помощью подключаемых стратегий. Настройка включает три шага.

    Шаг 1: регистрация локальной стратегии и стратегии JWT

    Локальная стратегия выполняется один раз при входе и отвечает за поиск пользователя по электронной почте и сравнение введённого пароля с сохранённым хешем. Стратегия JWT работает с каждым защищённым запросом: она извлекает токен из заголовка Authorization: Bearer, проверяет подпись с использованием JWT_SECRET и определяет пользователя, указанного в теле запроса. Экспорт заранее настроенного промежуточного компонента authenticateJWT с параметром session: false явно указывает на отсутствие состояния.

    const passport = require("passport");
    const LocalStrategy = require("passport-local").Strategy;
    const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
    
    // Local Strategy: Verify username and password during login.
    passport.use(
      new LocalStrategy(
        { usernameField: "email", passwordField: "password" },
        async (email, password, done) => {
          // Find the user and compare the hashed password.
          // If valid, return the user.
        }
      )
    );
    
    // JWT Strategy: Verify the token on protected requests.
    passport.use(
      new JwtStrategy(
        {
          jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
          secretOrKey: process.env.JWT_SECRET,
        },
        async (payload, done) => {
          // Find the user referenced in the token.
        }
      )
    );
    
    // Middleware
    const authenticateJWT = passport.authenticate("jwt", { session: false, });
    
    module.exports = { passport, authenticateJWT, };
    

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

    Шаг 2: аутентификация при входе и генерация токена

    Обработчик входа вызывает локальную стратегию через пользовательский обратный вызов. Ошибки передаются в механизм обработки ошибок Express; отсутствие пользователя приводит к коду 401, а успешная аутентификация генерирует токен, подписанный идентификатором и ролью пользователя, причем срок действия определяется по значению JWT_EXPIRES_IN по умолчанию — два часа.

    const jwt = require("jsonwebtoken");
    const passport = require("passport");
    
    const login = (req, res, next) => {
      passport.authenticate("local", { session: false }, (err, user, info) => {
        if (err) return next(err);
        if (!user) return res.status(401).json({ message: info.message, });
    
        // Issue a signed JWT after successful authentication.
        const token = jwt.sign(
          { id: user.id, role: user.role,},
          process.env.JWT_SECRET,
          { expiresIn: process.env.JWT_EXPIRES_IN || "2h",}
        );
    
        return res.status(200).json({ message: "Login successful.", token, user,});
      })(req, res, next);
    };
    

    Два момента заслуживают внимания. Короткий срок действия ограничивает время, в течение которого украденный токен остается полезным; если сессии должны длиться дольше, необходимо сочетать короткие токены доступа с процедурой обновления, подобной той, что описана в нашей стратегии обновления токенов для систем аутентификации Node.js. Кроме того, в ответе возвращается объект user в неизменном виде. Если этот объект представляет собой сырую запись из базы данных, он может содержать хэш пароля и внутренние поля, что и является чрезмерной утечкой данных, о которой говорилось ранее. Вместо этого следует возвращать только необходимые поля, такие как ID, email и роль.

    Шаг 3: защита защищенных маршрутов

    После экспорта мидлвэра защита маршрута заключается в размещении функции authenticateJWT перед контроллером в определении маршрута. Запросы без действительного токена отклоняются до выполнения любой бизнес-логики.

    const {  Router } = require("express");
    const authRouter = Router();
    
    // Get auth middleware and sample prorected controller
    const authController = require("../controllers/auth.controller");
    const { authenticateJWT } = require("../middleware/authentication");
    
    // Use JWT as a guard to protect certain routes
    authRouter.get("/me", authenticateJWT, authController.me);
    authRouter.patch("/password", authenticateJWT, authController.updatePassword);
    

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

    2. Авторизация и диапазоны OAuth: определение действий, разрешенных вызывающему

    Аутентификация отвечает на вопрос «Кто вы?»; авторизация — на вопрос «Что вам разрешено делать?». Обычно она выполняется сразу после аутентификации и проверяет каждую идентичность на соответствие правилам доступа перед тем, как разрешить или отклонить запрос. Без нее любой залогиненный пользователь может читать конфиденциальные данные или выполнять действия с привилегиями, и именно так возникают уязвимости типа BOLA.

    Три модели покрывают большинство случаев использования:

    • Контроль доступа на основе ролей (RBAC) предусматривает назначение прав ролям, а ролей — пользователям. API для блогов может иметь роли администратора, редактора и просмотра. Этот подход подходит для стабильных рабочих функций и групп, поэтому он часто используется в корпоративных приложениях.
    • Контроль доступа на основе атрибутов (ABAC) оценивает атрибуты пользователя, ресурса и среды запроса (отдел, степень конфиденциальности ресурса, время суток, сеть) с учетом установленных правил. Он подходит для API, чьи решения сильно зависят от контекста или часто меняются.
    • Контроль доступа на основе отношений (ReBAC) предоставляет доступ на основе отношений между пользователем и конкретным ресурсом, таких как право собственности или принадлежность к группе; это обычно проверяется путем анализа графа отношений. Этот подход идеально подходит для совместных продуктов, таких как обмен документами или социальные платформы.

    Самостоятельная настройка авторизации — отличный способ понять её нюансы, однако в производственных системах выдача и проверка токенов часто делегируются поставщику услуг идентификации. Когда API регистрируется у такого поставщика, как Microsoft Entra ID, и настраивается для приёма токенов типа bearer, чувствительные конечные точки проверяют диапазоны полномочий и роли каждого токена перед его выполнением. Недействительный токен или отсутствие разрешений приводят к ошибке 401 Unauthorized. В Express защищённый маршрут выглядит следующим образом:

    app.get(
      "/api/orders",
      passport.authenticate("oauth-bearer", { session: false }),
      (req, res) => {
        res.json({ message: "Protected resource." });
      }
    );
    

    Имейте в виду, что подтверждённый токен устанавливает лишь косвенные права доступа. Проверки на уровне объектов, такие как «Принадлежит ли этот заказ этому пользователю?», всё равно должны выполняться в вашем обработчике или слое данных, поскольку ни один поставщик идентификации не знает, кому принадлежит строка 4812 в вашей базе данных.

    Что касается самих протоколов, то следует опираться на отраслевые стандарты, такие как OAuth 2.0, OpenID Connect и SAML. Вы можете реализовать соответствующие процессы сами или передать их на обработку поставщикам идентификации, таким как Ping Identity, Okta, Microsoft Entra ID, AWS или IBM Security Verify.

    3. Шифрование: защита данных во время передачи и в состоянии покоя

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

    Основные подходы предполагают компромисс между скоростью и управлением ключами:

    • Симметричное шифрование использует один общий ключ. Оно очень быстрое и хорошо справляется с большими объемами данных, поэтому является подходящим выбором для шифрования данных в состоянии покоя и полезной нагрузки, однако обе стороны должны надежно хранить один и тот же секретный ключ.
    • Асимметричное шифрование использует пару публичного и приватного ключей, поэтому не требуется обмен общим секретом. Оно значительно медленнее и практично применимо только к небольшим объемам данных.
  • Гибридное шифрование сочетает в себе оба подхода: асимметричная криптография защищает симметричный ключ, а симметричный ключ защищает основные данные. Таким образом получаются преимущества обмена ключами первого подхода и скорость второго.
  • Для симметричного шифрования встроенный в Node модуль crypto поддерживает стандарт AES-256-GCM. Приведённый ниже код сериализует тело запроса, генерирует новый 12-байтовый вектор инициализации, шифрует данные и возвращает вектор инициализации, тег аутентификации GCM и зашифрованный текст в виде шестнадцатеричных строк.

    const crypto = require("crypto");
    
    const algorithm = "aes-256-gcm";
    const key = Buffer.from(process.env.ENCRYPTION_KEY, "hex");
    
    app.post("/api/orders", (req, res) => {
      const iv = crypto.randomBytes(12);
    
      const cipher = crypto.createCipheriv(algorithm, key, iv);
    
      const encrypted = Buffer.concat([
        cipher.update(JSON.stringify(req.body), "utf8"),
        cipher.final(),
      ]);
    
      const payload = {
        iv: iv.toString("hex"),
        tag: cipher.getAuthTag().toString("hex"),
        data: encrypted.toString("hex"),
      };
    
      // Store or transmit the encrypted payload
      res.json(payload);
    });
    

    Несколько факторов делают этот подход правильным. Длина ключа должна составлять ровно 32 байта (64 шестнадцатеричных символа в ENCRYPTION_KEY), и он должен поступать из менеджера секретов, а не из исходного кода. IV должен быть уникальным для каждой процедуры шифрования с использованием одного и того же ключа; повторное использование IV при работе с алгоритмом GCM приводит к катастрофическим последствиям, поэтому он генерируется для каждого запроса. Аутентификационная метка позволяет стороне, выполняющей дешифрование, обнаружить вмешательство, поэтому она должна храниться вместе с зашифрованным текстом и проверяться во время дешифрования. В реальном сервисе этот пакет данных сохраняется или пересылается дальше, а не возвращается вызывающей стороне, как это делается в демо-версии.

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

    const crypto = require("crypto");
    
    const encrypted = crypto.publicEncrypt(
      publicKey,
      Buffer.from("Sensitive API data")
    );
    

    Поскольку RSA может шифровать только данные меньшего размера, чем размер его ключа, функция publicEncrypt подходит для коротких значений, таких как поле с секретом или симметричный ключ, но не для целых документов. Именно этот ограничение устраняется гибридным подходом: генерируется временный ключ AES, им шифруются данные, а ключ AES затем шифруется с помощью публичного ключа RSA получателя, после чего оба элемента отправляются. Получатель использует свой приватный ключ для восстановления ключа AES, а затем дешифрует данные. Большинству API не понадобится использовать этот метод в коде приложения, поскольку TLS уже обеспечивает аналогичный обмен данными, но его стоит понимать для сценариев конечно-конечного шифрования.

    4. Проверка и очистка входных данных

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

    Сначала установите тип содержимого

    Самая простая проверка — это формат самого запроса. Этот небольшой мидлвэр использует метод req.is() для подтверждения значения Content-Type; в противном случае возвращает ответ 415 Unsupported Media Type. Его можно использовать глобально, для конкретного маршрутизатора или для отдельного эндпоинта.

    const requireContentType = (type) => (req, res, next) => {
      if (!req.is(type)) {
        return res.status(415).json({ error: "Unsupported Media Type", });
      }
    
      next();
    };
    
    app.post("/api/users", requireContentType("application/json"),
      (req, res) => {
        res.json({ message: "User created." });
      }
    );
    

    Проверка формы и смысла тела запроса

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

    const { body } = require("express-validator");
    const { getUserEmail } = require("../db/queries");
    
    const validateRegistration = [
      body("email")
        .isEmail()
        .withMessage("Invalid email format")
        .custom(async (value) => {
          if (await getUserEmail(value)) {
            throw new Error("Email is already in use");
          }
          return true;
        }),
    
      body("password")
        .isLength({ min: 8 })
        .withMessage("Password must be at least 8 characters long"),
    ];
    
    module.exports = { validateRegistration }
    

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

    const { validationResult } = require("express-validator");
    const { validateRegistration } = require("../validators/userValidator");
    
    app.post("/api/register", validateRegistration, (req, res) => {
      const errors = validationResult(req);
    
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }
    
      res.json({ message: "Registration successful." });
    });
    

    Очистка после проверки

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

    const sanitizeRegistration = [
      body("firstName").trim().escape(),
      body("email").normalizeEmail(),
    ];
    
    app.post(
      "/api/register",
      validateRegistration,
      sanitizeRegistration,
      (req, res) => {
        const errors = validationResult(req);
    
        if (!errors.isEmpty()) {
          return res.status(400).json({ errors: errors.array() });
        }
    
        res.json({ message: "Registration successful." });
      }
    );
    

    Здесь порядок действий имеет важное значение. Проверка уникальности в валидаторе выполняется до вызова функции normalizeEmail(), поэтому два варианта написания одного и того же адреса могут пройти проверку и привести к созданию дублирующихся учётных записей. Унификация данных до поиска или обеспечение уникальности уже унифицированных значений на уровне базы данных устраняют эту проблему. Также стоит тщательно подходить к использованию функции escape(): HTML-кодирование входных данных защищает шаблоны, отображающие эти значения, но изменяет хранящиеся данные; многие команды предпочитают хранить сырые данные и кодировать их при выводе. Если вы рассматриваете возможность использования библиотек валидации, наша статья «Zod и express-validator: два подхода к валидации в Express» рассматривает соответствующие плюсы и минусы.

    Используйте параметризованные запросы к базе данных

    Никогда не создавайте SQL-запросы путем объединения данных, введённых пользователем. Клиенты баз данных, такие как pg, и ORM-инструменты, такие как Prisma, поддерживают параметризованные запросы, которые отправляют текст запроса и значения отдельно, благодаря чему база данных всегда рассматривает введённые данные как данные, а не как исполняемый SQL-код.

    С помощью pg создайте пул подключений один раз и экспортируйте его для ваших модулей обработки данных:

    const { Pool } = require("pg");
    
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
    
    module.exports = pool;
    

    Затем в запросах используются пронумерованные местоимения ($1, $2), при этом значения передаются в отдельном массиве. Даже если в email присутствует кавычка, за которой следует DROP TABLE, она сохраняется как текстовая строка.

    app.post("/api/users", async (req, res) => {
      const { email, name } = req.body;
    
      await pool.query(
        "INSERT INTO users (email, name) VALUES ($1, $2)",
        [email, name]
      );
    
      res.status(201).json({ message: "User created." });
    });
    

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

    5. Ограничение частоты запросов

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

    Лимиты могут применяться по разным критериям:

    • По клиенту: запросы учитываются по каждому API-ключу или IP-адресу. Когда клиент достигает установленного лимита, ему приходится ждать сброса интервала или приобрести более высокий лимит, обычно в платной версии.
    • По географии или времени: ограничения различаются в зависимости от региона или временного периода — например, разрешается больше трафика из регионов, где находятся ваши клиенты, а ограничения ужесточаются там, откуда исходит подозрительный трафик.
    • По мощности сервера: некоторые части API направляются на специализированную инфраструктуру с собственными ограничениями, такую как небольшой пул фоновых процессов для выполнения ресурсоемких задач.

    Существует множество алгоритмов (фиксированное окно, скользящее окно, контейнер токенов), и вам не обязательно самостоятельно их реализовывать, чтобы начать использование. Модуль express-rate-limit по умолчанию подсчитывает количество запросов по IP-адресу. В приведенном ниже примере установлено общее ограничение в 100 запросов за 15 минут для всех ресурсов под адресом /api, а для процедуры входа — гораздо более строгое ограничение в пять попыток за пять минут, при этом для отклоненных запросов указано пользовательское сообщение.

    const rateLimit = require("express-rate-limit");
    
    // Apply to all API routes
    const apiLimiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 100,
    });
    
    // Apply stricter limits to authentication endpoints
    const loginLimiter = rateLimit({
      windowMs: 5 * 60 * 1000, // 5 minutes
      max: 5,
      message: "Too many login attempts. Please try again later.",
    });
    
    app.use("/api", apiLimiter);
    
    app.post("/api/login", loginLimiter, (req, res) => {
      res.json({ message: "Login successful." });
    });
    

    Общедоступные API обычно выдают каждому потребителю ключ, и ограничение по этому ключу является более справедливым, чем ограничение по IP-адресу, поскольку многие пользователи могут делиться одним адресом через корпоративный прокси. Специальный keyGenerator считывает заголовок X-API-Key и использует его в качестве идентификатора счетчика.

    const rateLimit = require("express-rate-limit");
    
    const apiKeyLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 1000,
      keyGenerator: (req) => req.get("X-API-Key"),
    });
    
    app.use("/api", apiKeyLimiter);
    

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

    Ограничение скорости обработки дорогостоящих эндпоинтов

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

    const rateLimit = require("express-rate-limit");
    
    // Throttle all API requests
    const apiThrottle = rateLimit({
      windowMs: 1000, // 1 second
      max: 10, // Allow up to 10 requests per second
    });
    
    // Apply a stricter throttle to resource-intensive endpoints
    const searchThrottle = rateLimit({
      windowMs: 2000, // 2 seconds
      max: 1, // Allow 1 request every 2 seconds
      message: "Please wait before sending another search request.",
    });
    
    app.use("/api", apiThrottle);
    
    app.get("/api/search", searchThrottle, (req, res) => {
      res.json({ results: [] });
    });
    

    Строго говоря, это всё ещё ограничение скорости с небольшими интервалами: избыточные запросы отклоняются с кодом 429, а не задерживаются. Если вы хотите настоящее тхроттлинговое ограничение, которое замедляет клиентов перед отказом им, необходим пакет вроде express-slow-down, добавляющий постепенные задержки. Также обратите внимание, что стандартный внутренний хранилище считает запросы по процессу, поэтому при использовании балансировщика нагрузки с несколькими инстансами требуется общее хранилище, такое как Redis, чтобы ограничения сохранялись. В новых версиях express-rate-limit опция max также называется limit; проверьте документацию к установленной версии. В качестве лёгкой альтернативы на TypeScript ознакомьтесь с нашей статьей о минимальном ограничителе скорости для Express.

    6. Логирование, мониторинг и обнаружение инцидентов

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

    Основные подходы и их недостатки:

    • Логирование запросов с использованием промежуточного программного обеспечения, такого как Morgan, позволяет недорого захватывать каждый входящий HTTP-запрос, но не даёт информации о состоянии системы.
    • Логирование приложений с использованием Winston, Pino или Bunyan фиксирует бизнес- и безопасностные события, такие как входы в систему, операции с базой данных и ошибки. Для крупных систем необходим структурированный вывод и централизованное хранение, чтобы сделать его полезным.
    • Метрики с Prometheus и Grafana отслеживают частоту запросов, задержку, использование CPU, памяти и уровень ошибок. Они показывают общие тенденции, но не информацию о том, что произошло в отдельном запросе.
    • Централизованное управление логами с использованием ELK Stack, OpenSearch, Splunk или CloudWatch объединяет логи из различных сервисов, однако это требует дополнительной инфраструктуры и усилий по эксплуатации.
    • Мониторинг производительности приложений с использованием Datadog, New Relic или Dynatrace объединяет логи, метрики, трейсы и функции оповещений в одной платформе, но это сопряжено с более высокими затратами на лицензирование и сложностью управления платформой.

    Любой из этих компонентов совместим с Express. Далее приведены четыре распространённых элемента.

    Логирование запросов с помощью Morgan

    При регистрации Morgan в режиме combined для каждого запроса записывается строка в стиле Apache, включающая метод, путь, статус, размер ответа и пользовательского агента.

    const express = require("express");
    const morgan = require("morgan");
    
    const app = express();
    
    // Log every incoming request
    app.use(morgan("combined"));
    
    app.get("/api/users", (req, res) => {
      res.json({ message: "Users retrieved successfully." });
    });
    

    Структурированная логгеризация приложений с помощью Winston

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

    const winston = require("winston");
    
    const logger = winston.createLogger({
      transports: [
        new winston.transports.Console(),
      ],
    });
    
    app.post("/api/orders", (req, res) => {
      logger.info("Order created", {
        userId: req.user.id,
        orderId: req.body.id,
      });
    
      res.status(201).json({ message: "Order created." });
    });
    

    Будьте осторожны с тем, что попадает в журналы логов. Идентификаторы пользователей — в порядке; пароли, токены, полные номера карт и весь содержимое запросов — нет, причем журналы логов часто становятся источником утечек конфиденциальных данных.

    Централизованное логирование ошибок

    Промежуточный компонент обработки ошибок Express, узнаваемый по своим четырем аргументам, ловит ошибки с любого маршрута. Он записывает сообщение об ошибке вместе с путем и методом и возвращает стандартный код 500, чтобы стек-трейсы и внутренние детали никогда не попадали к клиенту.

    app.use((err, req, res, next) => {
    /* Logger is built as an independent module or class */
      logger.error(err.message, {
        path: req.originalUrl,
        method: req.method,
      });
    
      res.status(500).json({
        error: "Internal Server Error",
      });
    });
    

    Обнаружение метрик для Prometheus

    Библиотека prom-client собирает стандартные метрики процесса Node.js и предоставляет их по адресу /metrics, чтобы Prometheus мог их собирать.

    const client = require("prom-client");
    
    client.collectDefaultMetrics();
    
    app.get("/metrics", async (req, res) => {
      res.set("Content-Type", client.register.contentType);
      res.end(await client.register.metrics());
    });
    

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

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

    Безопасность как непрерывный процесс

    Ни одна статья не может охватить всё, но перед любым выпуском вы можете убедиться, что ваш API соответствует этим базовым требованиям:

    • Каждый конечный пункт обслуживается исключительно по протоколу HTTPS.
    • Используется механизм OAuth или эквивалентный поток токенов.
    • Выдаваемые JWT имеют срок действия.
    • Все маршруты защищены ограничениями, более строгими — при входе.
    • Каждый входящий параметр проверяется перед использованием.
    • Запросы тестируются на наличие попыток вставки кода.
    • Проверяются правила доступа, включая проверки на уровне объектов.
    • Ключи и секреты хранятся вне кодовой базы.
    • Зафиксируются события, связанные с безопасностью.
    • Настроены панели управления и уведомления.
    • Ответы включают заголовки безопасности.
    • Клиенты никогда не видят трассировку стека или детали внутренних ошибок.
    • Зависимости обновлены и прошли аудит.

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

    Те же принципы лежат в основе развертывания приложений в облаке на таких платформах, как Azure, Google Cloud и AWS, которые добавляют свои собственные шлюзы, сервисы управления идентификацией и механизмы управления скоростью запросов. Практики, специфичные для облака, заслуживают отдельного рассмотрения, но вышеописанные слои уже в значительной степени готовят API к прохождению проверки на безопасность.

    Краткий аудит безопасности API Express

    • Разделяйте проверку учетных данных и аутентификацию на уровне каждого запроса: проверяйте пароль один раз, а затем используйте краткосрочные подписанные токены.
    • Аутентификация — это не авторизация. Проверяйте диапазоны полномочий и роли, а также убедитесь в принадлежности каждого объекта, к которому обращается запрос.
    • Для хранения данных в состоянии покоя используйте AES-GCM с уникальным IV для каждой операции; асимметричное шифрование оставьте для небольших значений и обмена ключами.
    • Внедряйте проверки типа контента, валидацию, очистку данных и параметризованные запросы, учитывая порядок их выполнения.
    • Устанавливайте лимиты на частоту запросов ко всему, применяйте более строгие ограничения к процедурам входа и ресурсам с высокой нагрузкой, а при работе с несколькими экземплярами используйте общий хранилище.
    • Фиксируйте события, связанные с безопасностью, не записывая секреты, и преобразуйте эти логи в уведомления, на которые кто-то сможет отреагировать.

    Связанная литература

  • Биометрическая аутентификация уровня повышенной безопасности в iOS и Android с ключами, привязанными к аппаратному обеспечению: Узнайте, почему само Face ID не является достаточным доказательством для вашего сервера, и как использовать Secure Enclave и ключи Android Keystore для подписи одноразовых запросов от сервера.
  • От загрузки к URL: безопасное хранение и обслуживание файлов пользователей в Express: Узнайте, где должны храниться загруженные файлы в приложениях Express, как функция express.static сопоставляет папки с URL-адресами, и какие меры защиты предотвращают превращение загрузок пользователей в угрозу безопасности.
  • Контроль доступа на основе ролей в Express с использованием JWT и двух промежуточных компонентов: Узнайте, как обеспечить контроль доступа на основе ролей в API Express путем сочетания промежуточного компонента аутентификации JWT с повторно используемым фильтром authorize(), а также когда следует возвращать коды 401 и 403.
  • Перенос JWT в React-приложении из localStorage в HttpOnly-кошельки: Узнайте, почему JWT в localStorage уязвимы к атакам типа XSS, как HttpOnly-кошельки с параметрами SameSite, ротация и BFF снижают риски, и какие проблемы остаются в React-приложениях.