Главная / Статьи / Защита API в Express: аутентификация, валидация данных, ограничение частоты запросов и мониторинг.

Защита API в Express: аутентификация, валидация данных, ограничение частоты запросов и мониторинг.

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

5092 слов

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

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

Почему API заслуживают особого внимания к безопасности

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

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

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

Стандарты и фреймворки, которые стоит знать

Стандарты безопасности 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. Аутентификация: подтверждение личности звонящего

    Каждый защищенный 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. Авторизация: определение действий, разрешенных вызывающему

    Аутентификация отвечает на вопрос «Кто вы?»; авторизация — на вопрос «Что вам разрешено делать?». Обычно она выполняется сразу после аутентификации и проверяет каждую идентичность на соответствие правилам доступа перед тем, как разрешить или отклонить запрос. Без нее любой авторизованный пользователь может читать конфиденциальные данные или выполнять действия с привилегиями, что и является причиной возникновения уязвимостей типа 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 защищает данные во время их передачи; шифрование при хранении защищает их в местах хранения, включая базы данных. Критически важные системы обычно используют оба метода, поскольку без шифрования данные, такие как финансовые учетные данные, могут быть перехвачены или извлечены из скомпрометированных хранилищ.

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

    • Симметричное шифрование использует один общий ключ. Оно очень быстрое и хорошо справляется с большими объемами данных, поэтому является подходящим выбором для шифрования данных в состоянии покоя и полезной нагрузки, однако обе стороны должны надежно хранить один и тот же секретный ключ.
    • Асимметричное шифрование использует пару публичного и приватного ключей, поэтому не требуется обмен общим секретом. Оно значительно медленнее и практично применимо только к небольшим объемам данных.
    • Гибридное шифрование сочетает в себе оба подхода: асимметричная криптография защищает симметричный ключ, а симметричный ключ защищает основной объем данных. Таким образом достигаются преимущества обмена ключами первого подхода и скорость второго.

    Для симметричного шифрования встроенный модуль crypto Node поддерживает стандарт 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. Ограничение частоты и треддинг

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

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

    • По клиенту: запросы учитываются по каждому 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.

    Основные выводы

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