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

Сервис на Express принимал заказы полгода без нареканий. Потом клиент прислал JSON, где quantity пришёл строкой "2" вместо числа, код сложил её с числом, получил "20" вместо 4, и в базу ушёл заказ на двадцать позиций. Разбор занял два дня, потому что в логах всё выглядело валидно. Причина одна: данные с границы системы попали внутрь без проверки типа. Этот чек-лист — про то, что закрыть в Node-сервисе на входе, прежде чем его увидит боевой трафик.
Материал для тех, кто уже пишет на Node (18.x и новее) и упёрся в вопрос «мы точно всё валидируем». Речь только про валидацию на границах: HTTP-тело, query, заголовки, переменные окружения, ответы внешних API. Схемы БД и бизнес-инварианты — отдельная тема, здесь их не трогаем.
Где именно проходит граница
Ошибка новичка — думать, что валидация это про поле в форме. На деле граница проходит везде, где в процесс попадают данные, которые вы не породили сами. Каждая такая точка — потенциальная дыра.
- Тело запроса —
req.body. Клиент может прислать что угодно, включая лишние поля и вложенные объекты не той формы. - Query и path-параметры — всегда строки.
req.query.page— это"3", а не3, даже если вы уверены в обратном. - Заголовки —
Content-Type,Authorization, кастомныеX-*. Их подделывают. - Переменные окружения —
process.env. Всё, что там лежит, — строка илиundefined.process.env.PORTбез парсинга ломаетlisten()тихо и неочевидно. - Ответы внешних сервисов — сторонний API поменял формат, и ваш код падает на
data.items.map, потому чтоitemsприслали какnull.
Правило простое: данные, которые вы не сгенерировали в этом процессе, не считаются проверенными, пока их не проверила схема. Доверие к внешнему источнику — это баг, а не оптимизация.
Схема, а не ручные проверки
Россыпь if (!req.body.email) return res.status(400)... не масштабируется: половину полей забудут, вторую половину проверят по-разному. Опишите вход декларативной схемой и проверяйте её одним вызовом. В экосистеме Node закрепились Zod и Joi — оба живые и активно поддерживаются.
Пример с Zod (3.x). Схема заодно выводит TypeScript-тип, так что после парсинга вы работаете с типизированным объектом:
import { z } from "zod";
// Схема тела запроса на создание заказа
const createOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
comment: z.string().max(500).optional(),
});
// Middleware для Express (4.x/5.x)
function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
// Отдаём машиночитаемый список ошибок, а не голый 400
return res.status(400).json({
error: "validation_failed",
details: result.error.issues,
});
}
// Дальше по цепочке идёт уже проверенный объект
req.validatedBody = result.data;
next();
};
}
app.post("/orders", validateBody(createOrderSchema), (req, res) => {
const { productId, quantity } = req.validatedBody;
// здесь quantity гарантированно число 1..100
});Ключевое здесь — quantity: z.number(). Именно эта строка не пропустила бы тот злополучный "2": строка не пройдёт проверку на число, и запрос вернёт 400 вместо порчи данных. Обратите внимание на safeParse, а не parse: первый возвращает результат, второй бросает исключение, и его придётся ловить.
Query-параметры парсим отдельно
Частая ловушка: query всегда строковый, поэтому z.number() на нём упадёт всегда. Нужен явный коэрсинг.
const listQuerySchema = z.object({
// z.coerce приводит "3" к числу перед проверкой
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});Что закрыть кроме формата поля
Проверка типа — это минимум. Перед продом закройте ещё несколько уровней, которые схема поля сама по себе не покрывает.
| Что проверяем | Зачем | Чем закрыть |
|---|---|---|
| Размер тела запроса | Гигабайтный JSON положит процесс по памяти | Лимит в парсере тела (например, express.json({ limit: "100kb" })) |
| Лишние поля | Клиент прислал isAdmin: true — mass assignment | Строгий режим схемы, отбрасывающий незнакомые ключи |
| Строковые границы | Комментарий на 10 МБ раздувает БД и ответы | .max() на каждой строке |
| Числовые границы | quantity: -5 или 999999999 | .positive(), .max(), .int() |
| Env при старте | Сервис поднялся с пустым DATABASE_URL и падает на первом запросе | Валидация process.env одной схемой на старте |
Про лишние поля стоит сказать отдельно. По умолчанию Zod-объект отбрасывает незнакомые ключи молча. Если хотите жёстко отклонять запрос с лишними полями — используйте .strict() на схеме объекта: тогда неизвестный ключ станет ошибкой валидации. Это защищает от mass assignment1, когда через тело запроса пытаются перезаписать поля, которых там быть не должно.
Env проверяем один раз на старте
Не размазывайте process.env.X || "default" по коду. Опишите окружение схемой и провалидируйте до того, как поднимется сервер. Пусть сервис откажется стартовать с кривым конфигом — это лучше, чем упасть на первом же запросе в проде.
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
});
// Падаем громко и сразу, если конфиг неполный
const env = envSchema.parse(process.env);
app.listen(env.PORT);Ответы внешних API — тоже вход
Свой код вы контролируете, чужой сервис — нет. Он может вернуть 200 с телом другой формы, пустой массив вместо объекта или null в поле, которое вчера было строкой. Прогоняйте ответ через схему так же, как тело запроса — особенно перед тем, как деструктурировать или мапить.
Здесь не нужно валидировать весь ответ целиком: описывайте только те поля, которые реально используете. Схема на весь чужой контракт будет ломаться при каждом их безобидном изменении.
Чек-лист перед выкаткой
- Каждый эндпоинт с телом имеет схему на
req.body, и она подключена как middleware, а не забыта. - Query- и path-параметры проходят коэрсинг и проверку границ, а не используются как есть.
- У парсера тела выставлен лимит размера.
- Строковые поля имеют
.max(), числовые —.min()/.max()и.int()где надо. - Схема окружения валидируется на старте, сервис не поднимается с неполным конфигом.
- Ответы внешних API проверяются перед использованием — хотя бы по нужным полям.
- Ошибка валидации возвращает 400 с машиночитаемым телом, а не 500 и не стектрейс наружу.
- Сообщения об ошибках не раскрывают внутренние имена полей и структуру, если сервис публичный.
Типичные ошибки на этом пути
- Валидация есть, но не подключена. Схема описана, middleware написан, но забыт в цепочке роута. Тест на «плохой» вход ловит это мгновенно — напишите хотя бы один.
- Отдаёте стектрейс клиенту. Необработанное исключение в проде отдаёт наружу пути к файлам и версии библиотек. Добавьте error-handling middleware, который в проде отвечает обобщённым 500.
- Валидируете, но не приводите типы. Проверили, что
page— число, но дальше по коду он всё ещё строка, потому что не сохранили результат парсинга. Работайте сresult.data, а не с исходнымreq.query. - Схема дублирует бизнес-логику. Валидация формата — не место для проверки «есть ли такой товар в наличии». Формат на границе, инварианты — в сервисном слое.
Что настроить дальше
После того как чек-лист закрыт, добавьте два инструмента прода. Первый — метрику доли 400-ответов по эндпоинтам: резкий рост часто означает не атаку, а поменявшийся формат у интегратора. Второй — логирование факта провала валидации (без самих данных, чтобы не утекли персональные), чтобы видеть, какие поля клиенты присылают криво чаще всего. Дальше стоит посмотреть в сторону единого формата ошибок для всего API и общей библиотеки схем, переиспользуемой между сервисами.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Zod — официальная документация, Express — Error Handling
Частые вопросы
Zod или Joi — что выбрать в 2024-м?
А если у меня Express 5, а не 4 — код сработает?
Валидация замедлит сервис под нагрузкой?
Что вернуть клиенту при ошибке валидации — 400 или 422?
Если валидация не сработала и битые данные уже в базе — что делать?
Нужно ли валидировать данные, которые пришли из своей же базы?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.