Перейти к содержимому

15. Best Practices: чеклист

☐ Пароли хэшируются bcrypt (rounds ≥ 12) или Argon2id
☐ Пароли НИКОГДА не логируются
☐ Пароли НИКОГДА не хранятся в открытом виде
☐ Минимальные требования к паролю: 8+ символов
☐ Проверка пароля на утечку (HaveIBeenPwned API)
☐ Ограничение попыток входа (rate limiting)
☐ Временная блокировка после N неудачных попыток
☐ API ключи и секреты в .env, НЕ в коде
import crypto from 'crypto';
async function isPasswordPwned(password) {
// Хэшируем пароль и отправляем только первые 5 символов SHA1
const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const response = await fetch(
`https://api.pwnedpasswords.com/range/${prefix}`
);
const text = await response.text();
const hashes = text.split('\n').map(line => line.split(':'));
return hashes.some(([hash]) => hash === suffix);
}
// При регистрации
async function validatePassword(password) {
if (password.length < 8) {
throw new Error('Password too short');
}
const isPwned = await isPasswordPwned(password);
if (isPwned) {
throw new Error('This password has been found in data breaches. Choose another.');
}
}
Окно терминала
npm install express-rate-limit
import rateLimit from 'express-rate-limit';
// Ограничение для login endpoint
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 5, // 5 попыток
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts, try again in 15 minutes' },
// Блокируем по IP + email
keyGenerator: (req) => `${req.ip}:${req.body.email}`,
// Не считаем успешные логины
skipSuccessfulRequests: true,
});
app.post('/login', loginLimiter, loginHandler);
// Глобальный rate limit для всего API
const globalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 минута
max: 100, // 100 запросов
});
app.use('/api/', globalLimiter);
☐ SESSION_SECRET длинный (64+ символов), случайный
☐ JWT_SECRET длинный (64+ символов), случайный
☐ Сессионные куки: HttpOnly + Secure + SameSite
☐ Access токены короткоживущие (15 мин)
☐ Refresh токены хранятся в HttpOnly cookie
☐ Session ID регенерируется после логина
☐ Logout уничтожает сессию на сервере
☐ JWT содержат только необходимые данные (не пароли!)
☐ JWT всегда имеют exp (срок истечения)
☐ HTTPS включён везде (редирект с HTTP)
☐ HSTS заголовок настроен
☐ CSP заголовок настроен
☐ X-Content-Type-Options: nosniff
☐ X-Frame-Options: DENY или SAMEORIGIN
☐ Referrer-Policy настроен
☐ CORS whitelist настроен (не *)
☐ Проверка на securityheaders.com: A или A+
☐ Все endpoint'ы требуют аутентификации (кроме публичных)
☐ Авторизация проверяется на сервере (не только на клиенте)
☐ Пользователь видит только свои данные (data isolation)
☐ RBAC/ABAC настроен
☐ Admin роут отделён и дополнительно защищён
☐ OAuth state параметр используется против CSRF
☐ PKCE используется для публичных OAuth клиентов
☐ XSS: все пользовательские данные экранированы
☐ XSS: Rich text санитизируется через DOMPurify
☐ CSRF: SameSite куки + CSRF токены для форм
☐ SQL Injection: используются Prepared Statements или ORM
☐ Path Traversal: проверяются пути к файлам
☐ SSRF: валидируются внешние URL в запросах
☐ Open Redirect: whitelist для redirect URL
// ОПАСНО
app.get('/redirect', (req, res) => {
res.redirect(req.query.url); // злоумышленник может перенаправить на свой сайт
});
// БЕЗОПАСНО
const allowedRedirects = ['/dashboard', '/profile', '/settings'];
app.get('/redirect', (req, res) => {
const url = req.query.url;
if (allowedRedirects.includes(url)) {
res.redirect(url);
} else {
res.redirect('/');
}
});
// ОПАСНО: пользователь может передать role: 'admin'
app.post('/profile', async (req, res) => {
await db.users.update(userId, req.body); // опасно!
});
// БЕЗОПАСНО: белый список полей
const { name, bio, avatar } = req.body;
await db.users.update(userId, { name, bio, avatar }); // только разрешённые поля
// ОПАСНО: сервер делает запрос по URL от пользователя
app.post('/fetch-url', async (req, res) => {
const data = await fetch(req.body.url); // может обратиться к внутренней сети!
res.json(data);
});
// БЕЗОПАСНО: валидация URL
function isAllowedURL(url) {
try {
const parsed = new URL(url);
// Только HTTP/HTTPS
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
// Запрет внутренних адресов
const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1'];
if (blockedHosts.includes(parsed.hostname)) return false;
// Запрет RFC 1918 диапазонов (приватные IP)
const privateRanges = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
if (privateRanges.test(parsed.hostname)) return false;
return true;
} catch {
return false;
}
}
import winston from 'winston';
const securityLogger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'security.log' })],
});
// Логируем важные события
function logSecurityEvent(event, details) {
securityLogger.info({
event,
timestamp: new Date().toISOString(),
...details,
});
}
// Примеры
logSecurityEvent('LOGIN_SUCCESS', { userId, ip: req.ip });
logSecurityEvent('LOGIN_FAILURE', { email, ip: req.ip, reason: 'invalid_password' });
logSecurityEvent('PASSWORD_RESET', { userId, ip: req.ip });
logSecurityEvent('ROLE_CHANGED', { userId, from: 'user', to: 'admin', changedBy: adminId });
logSecurityEvent('SUSPICIOUS_ACTIVITY', { userId, ip: req.ip, action: 'multiple_failed_logins' });
// Детектирование необычной активности
async function detectSuspiciousActivity(userId, ip) {
const recentFailures = await redis.incr(`login_failures:${userId}`);
await redis.expire(`login_failures:${userId}`, 3600); // 1 час
if (recentFailures >= 10) {
// Временная блокировка
await redis.setex(`user_blocked:${userId}`, 1800, '1'); // 30 минут
// Уведомление пользователя
await sendEmail(user.email, 'Подозрительная активность',
'Ваш аккаунт временно заблокирован из-за множественных неудачных попыток входа.'
);
// Алерт администратору
logSecurityEvent('ACCOUNT_BLOCKED', { userId, ip, failures: recentFailures });
}
}
Окно терминала
npm install speakeasy qrcode
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
// Генерация секрета для пользователя
function generate2FASecret(email) {
const secret = speakeasy.generateSecret({
name: `MyApp (${email})`,
length: 20,
});
return {
secret: secret.base32,
otpauthUrl: secret.otpauth_url,
};
}
// Генерация QR кода
async function generate2FAQR(otpauthUrl) {
return QRCode.toDataURL(otpauthUrl);
}
// Верификация кода
function verify2FA(secret, token) {
return speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
window: 1, // допуск ±30 секунд
});
}
// Роуты
app.post('/2fa/setup', authMiddleware, async (req, res) => {
const { secret, otpauthUrl } = generate2FASecret(req.user.email);
// Временно сохраняем секрет (не активируем пока не подтверждён)
await db.users.update(req.user.id, { twoFactorSecretTemp: encrypt(secret) });
const qrCode = await generate2FAQR(otpauthUrl);
res.json({ qrCode, secret }); // показываем QR + backup код
});
app.post('/2fa/verify', authMiddleware, async (req, res) => {
const user = await db.users.findById(req.user.id);
const secret = decrypt(user.twoFactorSecretTemp);
if (!verify2FA(secret, req.body.token)) {
return res.status(400).json({ error: 'Invalid code' });
}
// Активируем 2FA
await db.users.update(req.user.id, {
twoFactorSecret: encrypt(secret),
twoFactorEnabled: true,
twoFactorSecretTemp: null,
});
res.json({ success: true });
});
Окно терминала
# Проверка уязвимостей в зависимостях
npm audit
# Автоматическое исправление
npm audit fix
# Регулярные обновления
npx npm-check-updates -u && npm install

Оцени свой проект:

ОбластьБаллыМой статус
HTTPS + HSTS10
Security Headers (A+)15
Bcrypt/Argon2 для паролей15
Rate Limiting10
XSS защита10
CSRF защита10
RBAC авторизация10
Аудит логи10
2FA10
Итого100

80+ — отлично
60-79 — хорошо, есть над чем работать
< 60 — критические проблемы, нужно исправить немедленно