Проверка входных данных с серверной стороны в Express с использованием цепочек express-validator
Узнайте, почему API Express должны проверять входные данные на сервере, как цепочки express-validator записывают ошибки для использования в validationResult, и как структурировать маршрут для производственной среды.
Источники: Stop Trusting User Input: A Beginner’s Guide to `express-validator` in Express.js
Рано или поздно каждый API Express, принимающий входные данные, получает что-то вроде "age": "banana", и сервер либо выходит из строя, либо, что ещё хуже, сохраняет эти данные. Валидация — это механизм, предотвращающий это, и её правильное применение на стороне сервера критически важно, независимо от того, насколько осторожны формы на стороне клиента. В этом руководстве объясняется, что такое валидация и почему ответственность за неё лежит на сервере, показан метод ручной валидации и моменты его неэффективности, а также рассматривается внутренний механизм работы express-validator, методы, которые будут использоваться чаще всего, и пример реалистичного маршрута, объединяющего валидацию, очистку данных и проверки с использованием базы данных.
Что означает валидация
Проверка — это процесс подтверждения того, что входящие данные имеют ожидаемую структуру и содержимое, прежде чем ваш код начнёт с ними работать. Полезной аналогией может служить проверка удостоверения личности у входа: неважно, насколько уверенно кто-то выглядит, сотрудники всё равно проверяют его документ, и если он не соответствует требованиям, человека не пускают внутрь. Проверка применяется с такой же тщательностью ко каждому полю запроса. Действительно ли это адрес электронной почты? Это действительно число? Вообще ли это поле существует?
Рассмотрим конечную точку регистрации. Корректно сформированный тело запроса может выглядеть так:
{
"name": "Rahul Sharma",
"email": "rahul@example.com",
"age": 25,
"password": "supersecret123"
}
Проверка его означает подтверждение того, что email оформлен в формате адреса электронной почты, что age — это число, а не текст, и что password имеет минимальную длину, достаточную для того, чтобы она имела реальное значение.
Почему бэкенд не может полагаться на проверки фронтенда
Частым возражением является то, что форма регистрации уже отмечает поля как обязательные и проверяет формат электронной почты в браузере. Однако этого недостаточно, поскольку с точки зрения злоумышленника фронтенд является необязательным элементом. Любой может напрямую обратиться к вашему API с помощью Postman, curl или скрипта и обойти все правила, заданные в интерфейсе. Если сервер не проверяет данные сам, он фактически доверяет тому, кто отправляет запрос.
Конкретные преимущества проверки на стороне сервера:
- Это защищает ваши данные. Когда поле предназначено для числа, а в него вводится
"двадцать пять", возникает ошибка или, что ещё хуже, создается некорректная запись, вызывающая проблемы позже.
Разделение труда простое: валидация на стороне клиента предназначена для помощи пользователям, а валидация на стороне сервера — для защиты приложения. Обе важны, но только слой серверной валидации является обязательным.
Ручная валидация и причины, по которым она мешает масштабированию
Стоит сначала рассмотреть ручную версию. Вот начало маршрута регистрации, который извлекает поля из обработанного тела запроса (предполагается, что зарегистрирован парсер JSON вроде express.json()):
app.post("/register", (req, res) => {
const { name, email, age, password } = req.body;
Затем для каждого поля создается отдельная блок-клозура, возвращающая ответ кода 400:
if (!name || typeof name !== "string" || name.trim().length === 0) {
return res.status(400).json({ error: "Name is required" });
} if (!email || !email.includes("@")) {
return res.status(400).json({ error: "A valid email is required" });
} if (!age || typeof age !== "number" || age < 18) {
return res.status(400).json({ error: "Age must be a number and at least 18" });
} if (!password || password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
} // If we made it here, the data is safe to use
res.json({ message: "User registered successfully" });
});
Для небольшого приложения это совершенно приемлемо. Однако по мере роста кодовой базы накапливаются проблемы:
- Каждый маршрут, принимающий ввод, требует собственного блока операторов
if. - Проверки копируются между маршрутами, и исправление, внесенное в одну копию, легко упускается в других.
null, неверных типов, а также слишком коротких или слишком длинных значений — быстро становится утомительным.В ручной версии также присутствуют незаметные недостатки. Функция email.includes("@") принимает множество невалидных адресов, а вызов метода .includes с нестроковым значением приводит к ошибке. Такая повторяющаяся и подверженная ошибкам проверка — именно то, что должна обрабатывать библиотека.
Что предоставляет express-validator
express-validator — это набор промежуточных компонентов Express, построенный на основе библиотеки validator.js. Вместо написания условной логики для каждого поля достаточно задать правила, и библиотека будет сравнивать данные запроса с ними. Начните с импорта двух основных функций:
const { body, validationResult } = require("express-validator");
Тот же маршрут регистрации, переписанный с использованием декларативных правил, выглядит так:
app.post(
"/register",
[
body("name").trim().notEmpty().withMessage("Name is required"),
body("email").isEmail().withMessage("A valid email is required"),
body("age").isInt({ min: 18 }).withMessage("Age must be at least 18"),
body("password").isLength({ min: 8 }).withMessage("Password must be at least 8 characters"),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} res.json({ message: "User registered successfully" });
}
);
Теперь правила сформулированы почти простым языком: имя не должно быть пустым после обрезки, адрес электронной почты должен соответствовать формату электронного адреса, возраст должен быть целым числом не меньше 18, а пароль — состоять из не менее восьми символов. Обработчик в конце решает только один вопрос: произошла ли какая-либо ошибка?
Что происходит при поступлении запроса
Наиболее распространенное заблуждение у начинающих — ожидание того, что express-validator автоматически отклонит некорректные запросы. Это не так. Он собирает информацию о проблеме и полностью оставляет ответ на ваше усмотрение. Последовательность действий для каждого запроса следующая:
- Express запускает цепочку промежуточных компонентов, определенную для маршрута, и выполняет каждый элемент по порядку.
body("email").isEmail(), она считывает значение email из req.body и проверяет его. При сбое не происходит выбрасывания исключения и запрос не прерывается; цепочка записывает ошибку в объект запроса и продолжает работу.validationResult(req) считывает зарегистрированные ошибки. Это обычная функция, а не промежуточный компонент, поэтому её вызывают внутри обработчика. Она сама ничего не проверяет; она просто сообщает то, что уже собрали цепочки валидации.errors.isEmpty() возвращает false, вы отправляете ответ 400 с подробностями. В противном случае вы переходите к основной работе.Дизайн предпочитает гибкость удобству. Поскольку отклонение не происходит автоматически, вы контролируете формат и время ответа, а также можете выполнять дополнительную логику между проверками и окончательным решением. Недостаток заключается в том, что забыть вызвать validationResult() приводит к тихому отключению проверки для данного маршрута, поэтому многие команды оборачивают эту проверку в небольшой переиспользуемый мидлвэр.
Четыре основных компонента
Как только вы познакомитесь с составляющими, библиотека становится простой в использовании.
Цепочки проверок
Функции вроде body(), query(), param() и check() формируют цепочку: последовательность правил, соединённых точками, например body("email").trim().isEmail(). Каждое из них обрабатывает разную часть запроса:
body()считываетreq.body— содержимое в формате JSON или из формы.query()считываетreq.query— строку запроса после знака?, как в примере?page=2.param()считываетreq.params— параметры маршрута, такие как:idв адресе/users/:id.check()одновременно проверяет все части запроса.
Валидаторы
Валидаторы — это функции, которые проверяют значение: isEmail(), isInt(), isLength(), isAlphanumeric() и многие другие. Большинство из них обращаются к файлу validator.js, в котором уже содержится отработанная, тщательно протестированная логика для распространённых форматов.
Санитайзеры
Санитайзеры преобразуют данные, а не просто проверяют их. Функция trim() удаляет лишние пробелы вокруг значения, normalizeEmail() приводит адрес в стандартный вид, а escape() заменяет символы, значимые в HTML, на их эквиваленты-энтитеты, чтобы они не могли быть интерпретированы как разметка. Санитайзеры можно использовать вместе с валидаторами в одной цепочке, причём порядок имеет значение: если функция trim() размещена перед notEmpty(), значению, состоящему исключительно из пробелов, проверка не пройдёт.
Хранилище результатов за запрос
Каждый раз, когда запускаются цепочки валидации, их ошибки связываются с конкретным объектом req. Функция validationResult(req) просто считывает данные из этого хранилища, поэтому ей необходим тот же объект запроса, который использовались цепочки.
В совокупности процесс выглядит следующим образом: поступает запрос, запускаются цепочки валидации, которые тихо фиксируют возникшие проблемы, затем обработчик запрашивает результаты, и вы выбираете ответ. Если вы сравниваете этот подход с валидацией на основе схем, наша статья «Zod и express-validator» рассматривает соответствующие преимущества и недостатки.
Методы, подходящие для большинства реальных проектов
Вам не нужен весь набор API — небольшой набор методов покрывает большинство случаев.
Селекторы местоположения
body(field)— для полей в теле запросаquery(field)— для параметров строки запроса
param(field)— для параметров маршрута
Распространённые валидаторы
.notEmpty(): значение не должно быть пустым.isEmail(): значение должно соответствовать формату электронной почты.isInt({ min, max }): значение должно быть целым числом, при необходимости в заданных пределах.isLength({ min, max }): проверяет длину строки.isString(): значение должно быть строкой.isBoolean(): значение должно быть логическим значением.matches(regex): значение должно соответствовать пользовательскому шаблону.isIn([...values]): значение должно быть одним из элементов фиксированного набора; удобно для полей вродеstatus, которое может быть либо"active", либо"inactive"
Распространённые санитайзеры
.trim(): удаляет пробельные символы в начале и в конце.escape(): экранирует HTML-символы для снижения риска внедрения кода.normalizeEmail(): стандартизирует формат электронной почты.toInt()и.toFloat(): преобразуют строковые данные в числа
Собственные правила
.custom((value, { req }) => { ... }): позволяет реализовать собственное правило, когда нет подходящего встроенного валидатора, например для проверки того, что имя пользователя ещё не зарегистрировано
Отчёт об ошибках
.withMessage("text"): устанавливает сообщение для предыдущего правилаvalidationResult(req): собирает все ошибки для текущего запросаerrors.array(): преобразует эти ошибки в обычный массив, подходящий для JSON-ответа
Маршрут в стиле производства
Приведённый ниже пример ближе к тому, что содержит реальный API: маршрут для создания записи в блоге, объединяющий проверку данных, их очистку и запрос к базе данных. Сначала идут импорты, где Post обозначает модель, такую как схема Mongoose:
const express = require("express");
const { body, validationResult } = require("express-validator");
const Post = require("./models/Post"); // a Mongoose model, for example
Затем — маршрутизатор и сам маршрут с массивом правил:
const router = express.Router();router.post(
"/posts",
[
body("title")
.trim()
.notEmpty().withMessage("Title is required")
.isLength({ max: 100 }).withMessage("Title cannot exceed 100 characters"), body("slug")
.trim()
.notEmpty().withMessage("Slug is required")
.matches(/^[a-z0-9-]+$/).withMessage("Slug can only contain lowercase letters, numbers, and hyphens")
.custom(async (slug) => {
const existing = await Post.findOne({ slug });
if (existing) {
throw new Error("This slug is already taken");
}
return true;
}), body("content")
.trim()
.notEmpty().withMessage("Content cannot be empty")
.isLength({ min: 20 }).withMessage("Content should be at least 20 characters"), body("tags")
.optional()
.isArray().withMessage("Tags must be an array")
.custom((tags) => tags.every((t) => typeof t === "string"))
.withMessage("Each tag must be a string"),
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} try {
const post = await Post.create(req.body);
res.status(201).json(post);
} catch (err) {
res.status(500).json({ error: "Something went wrong while saving the post" });
}
}
);module.exports = router;
Стоит изучить несколько деталей:
- Правила, основанные на базе данных. Цепочка обработки
slugиспользует асинхронный валидатор.custom()для поиска существующих записей. Регулярное выражение может подтвердить формат slug, но только запрос к базе данных позволяет убедиться в его уникальности. Вызов функции внутри кастомного валидатора помечает поле как недействительное с соответствующим сообщением.
tags помечено как .optional(), поэтому пост без тегов принимается, но при их наличии они должны представлять собой массив строк.Два момента, на которые стоит обратить внимание перед копированием этого шаблона. Во-первых, проверка уникальности в валидаторе подвержена проблеме гонки: два запроса с одинаковым идентификатором могут пройти проверку до того, как будет произведено сохранение, поэтому необходимо вести уникальный индекс для этой колонки и обрабатывать ошибку дублирующегося ключа. Во-вторых, передача req.body непосредственно в Post.create() сохраняет все дополнительные поля, указанные клиентом, а не только те, которые были проверены. express-validator предоставляет функцию matchedData(req), которая возвращает только те поля, что покрываются вашими правилами валидации, и использование её помогает устранить проблему массового присваивания данных.
По мере роста приложения следует перемещать массивы правил в отдельные модули, например validators/postValidator.js, и импортировать их в файлы маршрутов, чтобы код валидации не превращался в огромные блоки.
Заключение
Проверка данных — это не опциональный элемент; она определяет, может ли ваше приложение доверять обрабатываемым им данным. Ручные проверки с использованием if подходят для небольшого количества полей, но быстро теряют эффективность при обработке большого числа маршрутов.
express-validator — это не волшебство. Он выполняет заявленные правила в качестве промежуточного модуля, фиксирует ошибки в запросе и позволяет функции validationResult() предоставить отчет, чтобы вы могли принять решение о ответе. Учитывая этот подход, остальная часть библиотеки представляет собой набор инструментов для проверки и очистки данных, которые можно выбирать по мере необходимости. Самые важные аспекты на практике:
- Считайте проверку на стороне сервера обязательной, независимо от проверок на стороне клиента.
- Всегда вызывайте функцию
validationResult(), желательно из одного общего промежуточного модуля, поскольку библиотека сама по себе никогда не отклоняет запросы. - Целенаправленно устанавливайте порядок выполнения операций очистки в цепочке.
- Верните валидаторы уникальности с ограничениями базы данных.
- Сохраняйте результат
matchedData(req), а не сырой содержимое запроса.