Безопасность Node-приложения

Урок о практической, оборонительной защите Node-сервиса: как не пустить во внутрь невалидный ввод, закрыть типовые дыры и сократить поверхность атаки.

Поверхность атаки — это все точки, через которые внешний мир может повлиять на ваше приложение: входные данные, заголовки, зависимости, права процесса.

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

Зачем это нужно на практике

Боты сканируют интернет круглосуточно, пробуя типовые уязвимости по словарю. Им не нужен ваш бизнес — им нужна любая дыра, чтобы украсть данные, разослать спам или встроить майнер. Поэтому защита — не «когда вырастем», а гигиена с первого дня. Хорошая новость: базовые меры дёшевы и дают непропорционально большой эффект.

Валидация ввода

Главный принцип: никогда не доверяйте входным данным — ни телу запроса, ни параметрам URL, ни заголовкам. Проверяйте тип, формат, длину и допустимый диапазон до того, как данные дойдут до логики. Удобно описывать схему через библиотеку валидации (zod, joi).

import { z } from 'zod';

const CreateUser = z.object({
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  name: z.string().min(1).max(100),
});

app.post('/users', (req, res) => {
  const result = CreateUser.safeParse(req.body);
  if (!result.success) {
    // не пускаем дальше — отвечаем 400, не раскрывая внутренностей
    return res.status(400).json({ error: 'invalid input' });
  }
  const user = result.data; // здесь данные уже проверены и типизированы
  // ...
});

Валидация на входе не только защищает — она упрощает остальной код: дальше вы работаете с данными, в которых уверены.

Инъекции

Инъекция возникает, когда пользовательский ввод попадает в команду (SQL, shell) как часть кода, а не как данные. Классика — конкатенация строки в SQL:

// ОПАСНО: ввод склеен в текст запроса
const q = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// ввод  ' OR '1'='1  превратит условие в всегда-истинное

Защита — параметризованные запросы (placeholders): драйвер сам экранирует значения, и ввод никогда не становится кодом.

// БЕЗОПАСНО: значение передано параметром, а не в текст
const res = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [req.body.email]
);

Тот же принцип — для команд ОС: избегайте exec(`cmd ${userInput}`); используйте execFile/spawn с массивом аргументов, где ввод не интерпретируется оболочкой. Общее правило: данные передаются отдельно от кода.

Заголовки безопасности (helmet)

Браузеры умеют защищать пользователя, если сервер пришлёт правильные HTTP-заголовки. Пакет helmet для Express выставляет разумный набор по умолчанию одной строкой.

import helmet from 'helmet';
app.use(helmet());

Что он, в частности, делает:

ЗаголовокЗачем
Content-Security-PolicyОграничивает источники скриптов — заслон против XSS
X-Content-Type-Options: nosniffЗапрещает браузеру «угадывать» MIME-тип
Strict-Transport-SecurityЗаставляет браузер ходить только по HTTPS
X-Frame-OptionsЗащита от clickjacking через встраивание в iframe

Отдельно отключите «болтливый» заголовок, выдающий стек: app.disable('x-powered-by') (helmet делает это сам). Чем меньше вы рассказываете о начинке, тем меньше подсказок атакующему.

Ограничение размера тела запроса

Если принимать тело запроса без лимита, злоумышленник пришлёт гигантский JSON и положит сервис по памяти — это простой вид DoS. Ставьте явный предел.

import express from 'express';
const app = express();

// тело JSON не больше 100 КБ
app.use(express.json({ limit: '100kb' }));

Подбирайте лимит под реальные нужды эндпоинта: для формы хватит десятков килобайт, для загрузки файлов используйте отдельный маршрут с потоковой обработкой и своим, осознанным пределом. Заодно полезно ограничивать частоту запросов (rate limiting), чтобы один клиент не исчерпал ресурсы.

Зависимости и npm audit

Современное Node-приложение — это сотни чужих пакетов в node_modules. Уязвимость в любом из них становится вашей. Поэтому аудит зависимостей — регулярная процедура, а не разовая.

# показать известные уязвимости в зависимостях
npm audit

# попытаться обновить до безопасных версий
npm audit fix

# в CI: упасть, если есть дыры уровня high и выше
npm audit --audit-level=high

Дополнительно: фиксируйте версии через package-lock.json и коммитьте его, ставьте в CI воспроизводимо через npm ci, не тащите пакеты ради одной функции и поглядывайте на их свежесть и репутацию. Меньше зависимостей — меньше поверхность атаки.

Не запускать от root

Если процесс работает под root, то взлом приложения = захват всей машины: атакующий читает любые файлы, ставит софт, ходит по сети. Запускайте Node под непривилегированным пользователем. В Docker это делает инструкция USER:

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# создаём и переключаемся на непривилегированного пользователя
USER node
CMD ["node", "server.js"]

Принцип называется least privilege — наименьшие необходимые права. Если сервису не нужно слушать порт ниже 1024 и писать в системные каталоги, ему незачем быть root. Тот же подход применим в systemd через директивы User= и ограничения сервиса.

Как это работает под капотом

Инъекция возможна, потому что интерпретатор (SQL-движок, shell) не различает «код» и «данные» сам по себе — он разбирает единый текст. Параметризованный запрос разрывает эту склейку: драйвер отправляет шаблон с placeholder отдельно от значений, и СУБД подставляет значения уже после разбора структуры запроса — поэтому ввод физически не может изменить смысл команды. Заголовки безопасности работают на стороне браузера: сервер лишь декларирует политику (например, «исполняй скрипты только со своего домена»), а применяет её браузер пользователя. Права процесса опираются на модель пользователей ОС: ядро проверяет UID при каждом обращении к файлу или порту, поэтому процесс под node-пользователем просто не имеет доступа к чужим ресурсам, даже если код скомпрометирован.

Частые ошибки

  • Доверие вводу. Данные используют без проверки типа, длины и диапазона — открыта дверь для мусора и атак.
  • Конкатенация ввода в SQL/команды. Прямой путь к инъекции; всегда параметризуйте.
  • Нет заголовков безопасности. Браузер не получает указаний и не защищает пользователя от XSS/clickjacking.
  • Тело запроса без лимита. Один большой запрос кладёт сервис по памяти.
  • Зависимости без аудита. Уязвимость в транзитивном пакете тихо становится вашей.
  • Процесс под root. Любая дыра в коде превращается в захват всей машины.

Итоги

  • Не доверяйте вводу: валидируйте тип, формат, длину и диапазон на входе.
  • Против инъекций используйте параметризованные запросы и аргументы-массивы — данные отдельно от кода.
  • Подключите helmet и не раскрывайте лишнего о начинке сервера.
  • Ограничивайте размер тела запроса и частоту обращений.
  • Регулярно гоняйте npm audit, фиксируйте lock-файл, держите зависимости в узде.
  • Запускайте процесс под непривилегированным пользователем (least privilege), а не под root.
Проверьте себя
1. Как правильно защититься от SQL-инъекции при работе с пользовательским вводом?
AВручную заменять одинарные кавычки в строке ввода
BИспользовать параметризованные запросы (placeholders), где значения передаются отдельно от текста запроса
CПринимать только короткие строки
DШифровать тело запроса
2. Почему Node-процесс в продакшене не следует запускать от пользователя root?
ARoot замедляет работу event loop
BПод root недоступны переменные окружения
CПри взломе приложения злоумышленник получает права root, то есть фактически всю машину
DNode вообще не запускается от root
3. Зачем ограничивать размер тела запроса, например express.json({ limit: '100kb' })?
AЧтобы ускорить парсинг JSON в браузере
BЧтобы злоумышленник не положил сервис гигантским телом запроса (простой DoS)
CЧтобы логи занимали меньше места
DЭто требование стандарта HTTP