Безопасность API: JWT и OAuth, проверка данных, ограничение частоты запросов, мониторинг.
Практическое реализование безопасности API: аутентификация и авторизация с использованием JWT и OAuth, шифрование, проверка входных данных, ограничение частоты запросов и ведение журналов для выявления атак.
Источники: 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. Он стандартизирует токен идентификации и способы его верификации клиентами, что обеспечивает взаимодействие между сервисами для однократной аутентификации и получения информации о профиле от поставщиков идентификации.
Эти инструменты обеспечивают прочную основу, хотя и не являются исчерпывающим списком.
Угрозы, от которых вы защищаетесь
Наиболее часто встречающиеся уязвимости в реальных API включают:
- Несоблюдение принципов аутентификации: слабая или отсутствующая проверка личности и ненадлежащая обработка сессий позволяют злоумышленнику украсть куки или токены и использовать их повторно для доступа к вашим сервисам.
- Несоблюдение принципов авторизации на уровне объектов (BOLA): API проверяет, вошел ли пользователь в систему, но не проверяет, разрешено ли ему работать с конкретной записью; поэтому изменение ID в URL может привести к раскрытию данных других пользователей или внутренних процессов.
- Вставка SQL-запросов: введенные злоумышленником данные объединяются с запросом и выполняются базой данных. Почти каждый клиент базы данных предоставляет механизм параметров для безопасной передачи значений.
- Вставка команд: ненадежные данные из запроса попадают в оболочку системы или процесс обработки команд, что позволяет злоумышленнику выполнять произвольные команды с привилегиями процесса вашего сервера.
Для каждого из этих случаев существует соответствующая практика программирования. Остальная часть данного руководства рассматривает их в шести уровнях, используя 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 для каждой операции; асимметричное шифрование оставьте для небольших значений и обмена ключами.
- Внедряйте проверки типа контента, валидацию, очистку данных и параметризованные запросы, учитывая порядок их выполнения.
- Устанавливайте лимиты на частоту запросов ко всему, применяйте более строгие ограничения к процедурам входа и ресурсам с высокой нагрузкой, а при работе с несколькими экземплярами используйте общий хранилище.
- Фиксируйте события, связанные с безопасностью, не записывая секреты, и преобразуйте эти логи в уведомления, на которые кто-то сможет отреагировать.
Связанная литература
- Когда авторизация с JWT становится зависимой от состояния: аргументы в пользу использования сессий на стороне сервера в Node: Узнайте, почему списки блокировки и хранилища обновлений делают авторизацию с JWT уязвимой, как сессии Express с поддержкой Postgres упрощают её работу и где всё ещё применимы JWT.
- Создание авторизации с JWT, готовой к использованию в производстве, в API на Node.js: Как хешировать пароли, выдавать краткосрочные JWT, добавлять токены обновления и механизмы отзыва, разделять полномочия, защищаться от атак типа brute force и проверять корректную ошибку авторизации.