Безопасность и укрепление
Обзор
Практики разработки, где безопасность на первом месте. Считай любой внешний ввод враждебным, любой секрет — священным, а любую проверку прав — обязательной. Безопасность не фаза, а ограничение на каждую строку кода, которая касается пользовательских данных, аутентификации или внешних систем.
Когда применять
- Строишь что-либо, принимающее пользовательский ввод
- Реализуешь аутентификацию или авторизацию
- Хранишь или передаёшь чувствительные данные
- Интегрируешься с внешними API или сервисами
- Добавляешь загрузку файлов, вебхуки или колбэки
- Работаешь с платежами или персональными данными
Процесс: сначала модель угроз
Меры защиты, прикрученные без модели угроз, — это догадки. Прежде чем укреплять, потрать пять минут на то, чтобы подумать как атакующий:
- Нанеси на карту границы доверия. Где недоверенные данные попадают в твою систему? HTTP-запросы, поля форм, загрузки файлов, вебхуки, сторонние API, очереди сообщений и вывод LLM. Каждая граница — поверхность атаки.
- Назови активы. Что стоит украсть или сломать? Учётные данные, персональные данные, платёжные данные, административные действия, движение денег.
- Прогони STRIDE по каждой границе — быстрая линза, а не ритуал:
| Угроза | Спроси | Типовая мера |
|---|---|---|
| Spoofing (подмена) | Может ли кто-то выдать себя за пользователя/сервис? | Аутентификация, проверка подписи |
| Tampering (искажение) | Могут ли данные быть изменены при передаче или хранении? | Проверки целостности, параметризованные запросы, HTTPS |
| Repudiation (отказ от действия) | Можно ли потом отрицать действие? | Аудит-логирование событий безопасности |
| Information disclosure (раскрытие) | Могут ли данные утечь? | Шифрование, белые списки полей, обобщённые ошибки |
| Denial of service (отказ в обслуживании) | Можно ли перегрузить? | Ограничение частоты, лимиты размера ввода, таймауты |
| Elevation of privilege (повышение прав) | Может ли пользователь получить права, которых не должен иметь? | Проверки авторизации, минимум привилегий |
- Пиши сценарии злоупотребления рядом со сценариями использования. Для каждой функции спроси: «как бы я это использовал во вред?» — и сделай это своим первым тестом.
Если ты не можешь назвать границы доверия для функции, ты не готов её защищать. Это OWASP A04: небезопасное проектирование — большинство утечек начинается в проектировании, а не в коде.
Трёхуровневая система границ
Всегда делать (без исключений)
- Валидировать весь внешний ввод на границе системы (маршруты API, обработчики форм)
- Параметризовать все запросы к БД — никогда не склеивать пользовательский ввод в SQL
- Кодировать вывод для предотвращения XSS (использовать автоэкранирование фреймворка и не обходить его)
- Использовать HTTPS для всей внешней связи
- Хешировать пароли через bcrypt/scrypt/argon2 (никогда не хранить открытым текстом)
- Устанавливать заголовки безопасности (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)
- Использовать куки с httpOnly, secure, sameSite для сессий
- Запускать штатный аудит обнаруженного пакетного менеджера по закоммиченному lock-файлу перед каждым релизом
Сначала спросить (нужно одобрение человека)
- Добавление новых потоков аутентификации или изменение логики авторизации
- Хранение новых категорий чувствительных данных (персональные данные, платёжная информация)
- Добавление интеграций с новыми внешними сервисами
- Изменение конфигурации CORS
- Добавление обработчиков загрузки файлов
- Изменение ограничения частоты запросов
- Выдача повышенных прав или ролей
Никогда не делать
- Никогда не коммитить секреты в систему контроля версий (ключи API, пароли, токены)
- Никогда не логировать чувствительные данные (пароли, токены, полные номера карт)
- Никогда не считать клиентскую валидацию границей безопасности
- Никогда не отключать заголовки безопасности ради удобства
- Никогда не использовать
eval()илиinnerHTMLс данными от пользователя - Никогда не хранить сессии в доступном клиенту хранилище (localStorage для токенов авторизации)
- Никогда не показывать стек-трейсы и внутренние детали ошибок пользователям
Паттерны предотвращения из OWASP Top 10
Это паттерны предотвращения, а не ранжирование. Порядок редакции 2021 года см. в справочной таблице ../../references/security-checklist.md.
Инъекции (SQL, NoSQL, команды ОС)
// ПЛОХО: SQL-инъекция через склейку строк
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// ХОРОШО: параметризованный запрос
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// ХОРОШО: ORM с параметризованным вводом
const user = await prisma.user.findUnique({ where: { id: userId } });
Сломанная аутентификация
// Хеширование паролей
import { hash, compare } from 'bcrypt';
const SALT_ROUNDS = 12;
const hashedPassword = await hash(plaintext, SALT_ROUNDS);
const isValid = await compare(plaintext, hashedPassword);
// Управление сессиями
app.use(session({
secret: process.env.SESSION_SECRET, // Из окружения, а не из кода
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Недоступна из JavaScript
secure: true, // Только по HTTPS
sameSite: 'lax', // Защита от CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 часа
},
}));
Межсайтовый скриптинг (XSS)
// ПЛОХО: отрисовка пользовательского ввода как HTML
element.innerHTML = userInput;
// ХОРОШО: использовать автоэкранирование фреймворка (React делает это по умолчанию)
return <div>{userInput}</div>;
// Если HTML отрисовывать ОБЯЗАТЕЛЬНО, сначала очисти
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);
Сломанный контроль доступа
// Всегда проверяй авторизацию, а не только аутентификацию
app.patch('/api/tasks/:id', authenticate, async (req, res) => {
const task = await taskService.findById(req.params.id);
// Проверить, что аутентифицированный пользователь владеет этим ресурсом
if (task.ownerId !== req.user.id) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: 'Not authorized to modify this task' }
});
}
// Продолжить обновление
const updated = await taskService.update(req.params.id, req.body);
return res.json(updated);
});
Небезопасная конфигурация
// Заголовки безопасности (для Express используй helmet)
import helmet from 'helmet';
app.use(helmet());
// Политика безопасности содержимого
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // По возможности ужесточить
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
}));
// CORS — ограничить известными источниками
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true,
}));
Раскрытие чувствительных данных
// Никогда не возвращай чувствительные поля в ответах API
function sanitizeUser(user: UserRecord): PublicUser {
const { passwordHash, resetToken, ...publicFields } = user;
return publicFields;
}
// Используй переменные окружения для секретов
const API_KEY = process.env.STRIPE_API_KEY;
if (!API_KEY) throw new Error('STRIPE_API_KEY not configured');
Подделка запросов на стороне сервера (SSRF)
Каждый раз, когда сервер загружает URL, на который повлиял пользователь — вебхуки, «импорт по ссылке», прокси изображений, превью ссылок, — атакующий может направить его на внутренние сервисы (метаданные облака, localhost, приватные адреса).
// ПЛОХО: загружаем всё, что дал пользователь
await fetch(req.body.webhookUrl);
// ХОРОШО: белый список схемы и хоста, отказ, если ЛЮБОЙ разрешённый IP приватный, запрет перенаправлений
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (url.protocol !== 'https:') throw new Error('https only');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed');
// Разрешаем ВСЕ записи; один приватный/зарезервированный адрес проваливает проверку.
const addrs = await lookup(url.hostname, { all: true });
if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) {
throw new Error('private/reserved IP');
}
return url;
}
await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' });
Проверка range() !== 'unicast' покрывает петлевые адреса, link-local 169.254.169.254 (метаданные облака, цель SSRF номер один), приватные и unique-local диапазоны в IPv4 и IPv6.
Оговорка — здесь остаётся разрыв TOCTOU. fetch заново разрешает DNS после проверки, поэтому атакующий с записью с коротким TTL может переключиться на внутренний адрес между валидацией и соединением. Для поверхностей с высоким риском разреши имя один раз и соединяйся с зафиксированным адресом либо поставь спереди фильтрующий агент (request-filtering-agent / ssrf-req-filter).
Паттерны валидации ввода
Валидация по схеме на границах
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200).trim(),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
dueDate: z.string().datetime().optional(),
});
// Валидация в обработчике маршрута
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: result.error.flatten(),
},
});
}
// result.data теперь типизирован и провалидирован
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
Безопасная загрузка файлов
// Ограничивай типы и размеры файлов
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5 МБ
function validateUpload(file: UploadedFile) {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
throw new ValidationError('File type not allowed');
}
if (file.size > MAX_SIZE) {
throw new ValidationError('File too large (max 5MB)');
}
// Не доверяй расширению файла — если это критично, проверяй сигнатуру байтов
}
Разбор результатов аудита зависимостей
Аудиты пакетных менеджеров сообщают об известных уязвимостях; они не доказывают, что пакет заслуживает доверия, и что уязвимый код достижим. Используй это дерево решений:
Штатный аудит пакетного менеджера сообщает об уязвимости
├── Серьёзность: критичная или высокая
│ ├── Достижим ли уязвимый код в путях рантайма, сборки, тестов или выкатки?
│ │ ├── ДА → чинить немедленно (обновить, пропатчить или заменить зависимость)
│ │ └── НЕТ (подтверждено, что не используется ни в одном из этих путей) → чинить скоро, но это не блокер
│ └── Есть ли исправление?
│ ├── ДА → обновиться до пропатченной версии
│ └── НЕТ → искать обходные пути, рассмотреть замену зависимости или внести в белый список с датой пересмотра
├── Серьёзность: средняя
│ ├── Достижимо в продакшне? → чинить в следующем релизном цикле
│ └── Только для разработки? → чинить, когда удобно, вести в бэклоге
└── Серьёзность: низкая
└── Отслеживать и чинить при плановых обновлениях зависимостей
Ключевые вопросы:
- Вызывается ли уязвимая функция в твоём пути выполнения?
- Это зависимость рантайма или только для разработки?
- Эксплуатируема ли уязвимость в твоём контексте выкатки (например, серверная уязвимость в чисто клиентском приложении)?
Когда откладываешь исправление, задокументируй причину и назначь дату пересмотра.
Гигиена цепочки поставок
Не предполагай, что это npm, и не считай ближайший манифест корнем установки. Действуй в таком порядке:
- Найди границу установки и менеджер. Используй корень рабочего пространства, которому принадлежит lock-файл, либо независимый вложенный проект — но только если он вне этого рабочего пространства. Там сверь
packageManager(если есть), lock-файл и CI; останавливайся при расхождении или конкурирующих lock-файлах. Зафиксируй версию менеджера и используй таблицу соответствий из../../references/security-checklist.md. - Блокируй скрипты зависимостей до первого выполнения. Разворачивай проект с отключёнными скриптами или задокументированной политикой отказа по умолчанию, изучи исходники ожидающих скриптов, одобри минимально необходимые пакеты, закоммить политику, затем проверь чистой замороженной/неизменяемой установкой. Никогда не одобряй скрипты оптом.
Аудиты находят только известные уязвимости; они не поймают свежий вредоносный или тайпсквоттинговый пакет. Поэтому:
- Никогда не применяй принудительное автоисправление аудита (
npm audit fix --forceи аналоги). Посмотри, что предлагается, прочитай changelog и протестируй каждое получившееся обновление; принудительные исправления могут выйти за объявленные диапазоны версий. - Проверяй подписи и происхождение в реестре, где это поддерживается (
npm audit signatures,pnpm audit signatures), и считай их отсутствие поводом разобраться, а не автоматическим доказательством компрометации. - Рассматривай новые зависимости, дифф lock-файла и изменения политики скриптов вместе — владение, сопровождение, возраст релиза, происхождение, транзитивный граф и тайпсквоттинг вроде
cross-envпротивcrossenv(OWASP A06, LLM03).
Ограничение частоты запросов
import rateLimit from 'express-rate-limit';
// Общий лимит для API
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 100, // 100 запросов за окно
standardHeaders: true,
legacyHeaders: false,
}));
// Более строгий лимит для эндпоинтов аутентификации
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 попыток за 15 минут
}));
Управление секретами
Файлы .env:
├── .env.example → Коммитится (шаблон со значениями-заглушками)
├── .env → НЕ коммитится (содержит настоящие секреты)
└── .env.local → НЕ коммитится (локальные переопределения)
В .gitignore обязательно должны быть:
.env
.env.local
.env.*.local
*.pem
*.key
Всегда проверяй перед коммитом:
# Проверить, не попали ли секреты в индекс случайно
git diff --cached | grep -i "password\|secret\|api_key\|token"
Если секрет когда-либо попал в коммит — меняй его. Удалить строку или переписать историю недостаточно: считай его скомпрометированным с момента попадания на удалённый сервер. Сначала отзови и перевыпусти ключ, потом вычищай его из истории.
Защита функциональности с ИИ / LLM
Если твоё приложение вызывает LLM — чат-боты, суммаризаторы, агенты, RAG, — оно наследует новую поверхность атаки. Соотнеси её с OWASP Top 10 для LLM-приложений (2025):
- Считай любой вывод модели недоверенным вводом (LLM05: неправильная обработка вывода). Никогда не передавай вывод LLM напрямую в
eval, SQL, оболочку,innerHTMLили путь к файлу. Валидируй и кодируй его ровно так же, как сырой пользовательский ввод. - Исходи из того, что промпты можно перехватить (LLM01: инъекция промпта). Недоверенный текст в окне контекста — сообщение пользователя, загруженная веб-страница, PDF — может нести инструкции. Системный промпт не является границей безопасности; обеспечивай права в коде, а не в промпте.
- Держи секреты и данные других пользователей вне промптов (LLM02 / LLM07). Всё, что попало в контекст, может быть выдано обратно. Не помещай ключи API, данные других арендаторов и системный промпт целиком туда, где модель может это повторить.
- Ограничивай права инструментов и агентов (LLM06: избыточная агентность). Ограничивай инструменты минимумом, требуй подтверждения для разрушительных и необратимых действий и валидируй каждый аргумент инструмента.
- Ограничивай потребление (LLM10: неограниченное потребление). Ограничивай токены, частоту запросов и глубину циклов/рекурсии, чтобы специально составленный ввод не мог накрутить расходы или подвесить систему.
- Изолируй данные поиска (LLM08: слабости векторов и эмбеддингов). В RAG считай векторное хранилище границей доверия: разделяй эмбеддинги по арендаторам, чтобы один пользователь не мог достать данные другого, и валидируй документы до индексации, чтобы отравленное содержимое не могло направлять ответы.
// ПЛОХО: доверять выводу модели как команде или как разметке
const sql = await llm.generate(`Write SQL for: ${userQuestion}`);
await db.query(sql); // выполнение произвольного запроса
container.innerHTML = await llm.reply(userMessage); // хранимый XSS, через модель
// ХОРОШО: вывод модели — это данные: защитно разобрать, потом провалидировать, потом закодировать
let intent;
try {
intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage)));
} catch {
throw new ValidationError('unexpected model output'); // JSON.parse или схема не прошли
}
await runAllowlistedAction(intent.action, intent.params);
container.textContent = await llm.reply(userMessage);
Чеклист ревью безопасности
### Аутентификация
- [ ] Пароли хешируются через bcrypt/scrypt/argon2 (раундов соли ≥ 12)
- [ ] Токены сессий с httpOnly, secure, sameSite
- [ ] На входе есть ограничение частоты запросов
- [ ] Токены сброса пароля истекают
### Авторизация
- [ ] Каждый эндпоинт проверяет права пользователя
- [ ] Пользователи имеют доступ только к своим ресурсам
- [ ] Административные действия требуют проверки роли администратора
### Ввод
- [ ] Весь пользовательский ввод валидируется на границе
- [ ] SQL-запросы параметризованы
- [ ] HTML-вывод кодируется/экранируется
- [ ] Серверные загрузки по URL идут по белому списку (нет SSRF к внутренним сервисам)
### Данные
- [ ] Секретов нет ни в коде, ни в системе контроля версий
- [ ] Чувствительные поля исключены из ответов API
- [ ] Персональные данные шифруются при хранении (если применимо)
### Инфраструктура
- [ ] Настроены заголовки безопасности (CSP, HSTS и прочие)
- [ ] CORS ограничен известными источниками
- [ ] Зависимости проверены на уязвимости
- [ ] Сообщения об ошибках не раскрывают внутренности
### Цепочка поставок
- [ ] Закоммичен один авторитетный lock-файл; CI использует замороженную/неизменяемую установку этого менеджера
- [ ] Находки штатного аудита разобраны по достижимости и риску исправления; скрипты установки зависимостей заблокированы, если не одобрены явно
- [ ] Новые зависимости проверены (владение, происхождение, возраст релиза, транзитивный граф)
### ИИ / LLM (если используется)
- [ ] Вывод модели считается недоверенным (нет eval/SQL/innerHTML/оболочки)
- [ ] Секреты и данные других пользователей не попадают в промпты
- [ ] Права инструментов/агентов ограничены; разрушительные действия требуют подтверждения
См. также
Подробные чеклисты безопасности и шаги проверки перед коммитом — см. ../../references/security-checklist.md.
Типовые самооправдания
| Самооправдание | Как на самом деле |
|---|---|
| «Это внутренний инструмент, безопасность не важна» | Внутренние инструменты тоже компрометируют. Атакующие целятся в слабое звено. |
| «Добавим безопасность потом» | Прикрутить безопасность задним числом в 10 раз труднее, чем заложить её сразу. Добавляй сейчас. |
| «Никто не станет это эксплуатировать» | Автоматические сканеры найдут. Безопасность через неизвестность — не безопасность. |
| «Фреймворк заботится о безопасности» | Фреймворки дают инструменты, а не гарантии. Их всё равно нужно правильно применять. |
| «Это же просто прототип» | Прототипы становятся продакшном. Привычки безопасности с первого дня. |
| «Моделирование угроз здесь избыточно» | Пять минут вопроса «как бы я это атаковал?» предотвращают проектные дефекты, которые потом не залатать никакими мерами. |
| «Это же вывод LLM, просто текст» | Этот «текст» может быть SQL-запросом, тегом script или командой оболочки. Относись к нему как к любому недоверенному вводу. |
| «Аудит прошёл, значит зависимость безопасна» | Аудиты сопоставляют с известными уязвимостями. Они не обнаруживают свежий вредоносный пакет и не делают непроверенные скрипты установки безопасными для выполнения. |
Тревожные признаки
- Пользовательский ввод передаётся напрямую в запросы к БД, команды оболочки или отрисовку HTML
- Секреты в исходном коде или истории коммитов
- Эндпоинты API без проверок аутентификации и авторизации
- Отсутствующая конфигурация CORS или подстановочный символ (
*) в источниках - Нет ограничения частоты запросов на эндпоинтах аутентификации
- Стек-трейсы или внутренние ошибки показываются пользователям
- Зависимости с известными критичными уязвимостями, конкурирующие lock-файлы на одной границе установки, невоспроизводимые установки или скрипты, одобренные оптом
- Сервер загружает пользовательские URL без белого списка (SSRF)
- Вывод LLM/модели передаётся в запрос, в DOM, в оболочку или в
eval - Секреты, персональные данные или системный промпт целиком помещены в окно контекста LLM
Проверка
После реализации кода, значимого для безопасности:
- У штатного аудита нет неустранённых достижимых находок критичной/высокой серьёзности; CI сохраняет авторитетный lock-файл и блокирует непроверенные скрипты зависимостей
- Секретов нет ни в исходном коде, ни в истории git
- Весь пользовательский ввод валидируется на границах системы
- Аутентификация и авторизация проверяются на каждом защищённом эндпоинте
- Заголовки безопасности присутствуют в ответе (проверь через DevTools браузера)
- Ответы с ошибками не раскрывают внутренних деталей
- Ограничение частоты запросов активно на эндпоинтах аутентификации
- Серверные загрузки по URL проверяются по белому списку (нет SSRF)
- Вывод LLM/модели валидируется и кодируется перед использованием (если есть функциональность с ИИ)