Автентифікація, авторизація, валідація вхідних даних, rate limiting, CORS, керування секретами та OWASP API Top 10. Що я перевіряю перед кожним продакшн-деплоєм.
Я випускав API, які були повністю відкриті. Не зловмисно, не через лінощі — я просто не знав того, чого не знав. Ендпоінт, що повертав усі поля об'єкта користувача, включно з хешованими паролями. Rate limiter, який перевіряв лише IP-адреси, а отже будь-хто за проксі міг бомбити API. Реалізація JWT, де я забув перевірити claim iss, тому токени від зовсім іншого сервісу чудово працювали.
Кожна з цих помилок потрапила в продакшн. Кожну з них виявили — деякі я сам, деякі користувачі, одну — дослідник безпеки, який був досить люб'язний, щоб надіслати мені електронного листа замість того, щоб публікувати це в Twitter.
Цей пост — чеклист, який я побудував на основі тих помилок. Я проходжу його перед кожним продакшн-деплоєм. Не тому, що я параноїк, а тому, що я навчився: баги безпеки — це ті, що болять найбільше. Зламана кнопка дратує користувачів. Зламаний потік автентифікації витікає їхні дані.
Ці два слова вживають як взаємозамінні на нарадах, у документації, навіть у коментарях до коду. Вони не одне й те саме.
Автентифікація відповідає: «Хто ти?» Це крок входу в систему. Ім'я користувача та пароль, потік OAuth, magic link — все, що підтверджує твою особу.
Авторизація відповідає: «Що тобі дозволено робити?» Це крок дозволів. Чи може цей користувач видалити цей ресурс? Чи може він отримати доступ до цього адміністративного ендпоінта? Чи може він прочитати дані іншого користувача?
Найпоширеніший баг безпеки, який я бачив у продакшн API — це не зламаний потік входу. Це відсутня перевірка авторизації. Користувач автентифікований — у нього є валідний токен — але API ніколи не перевіряє, чи дозволено йому виконувати дію, яку він запитує.
JWT зустрічаються повсюди. Їх також повсюди неправильно розуміють. JWT має три частини, розділені крапками:
header.payload.signature
Header вказує, який алгоритм використовувався. Payload містить claims (ID користувача, ролі, термін дії). Signature доводить, що ніхто не підробив перші дві частини.
Ось правильна верифікація JWT у Node.js:
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
interface TokenPayload {
sub: string;
role: "user" | "admin";
iss: string;
aud: string;
exp: number;
iat: number;
jti: string;
}
function verifyToken(token: string): TokenPayload {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ["HS256"], // Ніколи не дозволяй "none"
issuer: "api.yourapp.com",
audience: "yourapp.com",
clockTolerance: 30, // 30 секунд допуску на розсинхронізацію годинника
}) as TokenPayload;
return payload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new ApiError(401, "Token expired");
}
if (error instanceof jwt.JsonWebTokenError) {
throw new ApiError(401, "Invalid token");
}
throw new ApiError(401, "Authentication failed");
}
}Декілька речей, на які слід звернути увагу:
algorithms: ["HS256"] — Це критично важливо. Якщо ти не вкажеш алгоритм, атакуючий може надіслати токен із "alg": "none" у header і повністю обійти верифікацію. Це атака alg: none, і вона вражала реальні продакшн-системи.
issuer та audience — Без них токен, створений для Сервісу A, працює на Сервісі B. Якщо ти запускаєш кілька сервісів із спільним секретом (чого не варто робити, але люди роблять), саме так відбувається міжсервісне зловживання токенами.
Специфічна обробка помилок — Не повертай "invalid token" для кожної помилки. Розрізнення між простроченим та невалідним допомагає клієнту зрозуміти, чи потрібно оновити токен, чи повторно автентифікуватися.
Access token повинні бути короткоживучими — 15 хвилин є стандартом. Але ти не хочеш, щоб користувачі вводили свій пароль кожні 15 хвилин. Ось де на сцену виходять refresh token.
Патерн, який насправді працює в продакшені:
import { randomBytes } from "crypto";
import { redis } from "./redis";
interface RefreshTokenData {
userId: string;
family: string; // Сімейство токенів для виявлення ротації
createdAt: number;
}
async function rotateRefreshToken(
oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
if (!tokenData) {
// Токен не знайдено — або протермінований, або вже використаний.
// Якщо вже використаний, це потенційна replay-атака.
// Інвалідуємо все сімейство токенів.
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// Видаляємо старий токен негайно — лише одноразове використання
await redis.del(`refresh:${oldRefreshToken}`);
// Генеруємо нові токени
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// Зберігаємо новий refresh token з тим самим сімейством
await redis.setex(
`refresh:${newRefreshToken}`,
60 * 60 * 24 * 30, // 30 днів
JSON.stringify({
userId: data.userId,
family: data.family,
createdAt: Date.now(),
})
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async function invalidateTokenFamily(family: string): Promise<void> {
// Скануємо всі токени в цьому сімействі та видаляємо їх.
// Це ядерний варіант — якщо хтось повторно використовує refresh token,
// ми вбиваємо кожен токен у сімействі, примушуючи повторну автентифікацію.
const keys = await redis.keys(`refresh:*`);
for (const key of keys) {
const data = await redis.get(key);
if (data) {
const parsed = JSON.parse(data) as RefreshTokenData;
if (parsed.family === family) {
await redis.del(key);
}
}
}
}Концепція сімейства токенів — це те, що робить цю схему безпечною. Кожен refresh token належить до сімейства (створеного при вході). Коли ти ротуєш, новий токен успадковує сімейство. Якщо атакуючий повторно використовує старий refresh token, ти виявляєш повторне використання та знищуєш усе сімейство. Легітимний користувач виходить із системи, але атакуючий не потрапляє всередину.
Ці дебати тривають роками, і відповідь зрозуміла: httpOnly cookies для refresh token, завжди.
localStorage доступний для будь-якого JavaScript, що працює на твоїй сторінці. Якщо у тебе є хоч одна XSS-вразливість — а в масштабі вона рано чи пізно з'явиться — атакуючий може прочитати токен та ексфільтрувати його. Гра закінчена.
httpOnly cookies недоступні для JavaScript. Крапка. XSS-вразливість все ще може робити запити від імені користувача (бо cookies надсилаються автоматично), але атакуючий не може вкрасти сам токен. Це суттєва різниця.
// Встановлення безпечного cookie для refresh token
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // Недоступний через JavaScript
secure: true, // Тільки HTTPS
sameSite: "strict", // Без cross-site запитів
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 днів
path: "/api/auth", // Надсилається лише до auth ендпоінтів
});
}path: "/api/auth" — це деталь, яку більшість людей пропускає. За замовчуванням cookies надсилаються до кожного ендпоінта на твоєму домені. Твоєму refresh token не потрібно потрапляти до /api/users чи /api/products. Обмеж шлях, зменши поверхню атаки.
Для access token я тримаю їх у пам'яті (змінна JavaScript). Не localStorage, не sessionStorage, не cookie. У пам'яті. Вони короткоживучі (15 хвилин), і коли сторінка перезавантажується, клієнт тихо звертається до refresh ендпоінта, щоб отримати новий. Так, це означає додатковий запит при завантаженні сторінки. Це того варте.
Клієнт — не твій друг. Клієнт — це незнайомець, який зайшов до тебе додому і сказав: «Мені дозволено тут бути.» Ти все одно перевіряєш його посвідчення.
Кожен фрагмент даних, що надходить ззовні твого сервера — тіло запиту, параметри запиту, URL-параметри, заголовки — це ненадійний вхід. Не має значення, що твоя React-форма має валідацію. Хтось обійде її за допомогою curl.
Zod — це найкраще, що сталося з валідацією вхідних даних у Node.js. Він дає тобі валідацію під час виконання з типами TypeScript безкоштовно:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z
.string()
.email("Invalid email format")
.max(254, "Email too long")
.transform((e) => e.toLowerCase().trim()),
password: z
.string()
.min(12, "Password must be at least 12 characters")
.max(128, "Password too long") // Запобігання bcrypt DoS
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain uppercase, lowercase, and a number"
),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
role: z.enum(["user", "editor"]).default("user"),
// Зверни увагу: "admin" навмисно не є варіантом тут.
// Призначення ролі admin проходить через окремий, привілейований ендпоінт.
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Використання в обробнику Express
app.post("/api/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
// result.data повністю типізований як CreateUserInput
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});Кілька деталей, що стосуються безпеки:
max(128) для пароля — bcrypt має обмеження вхідних даних у 72 байти, і деякі реалізації просто тихо обрізають. Але важливіше, якщо ти дозволиш пароль у 10 МБ, bcrypt витратить значний час на його хешування. Це вектор DoS-атаки.max(254) для email — RFC 5321 обмежує адреси електронної пошти до 254 символів. Будь-що довше — це не валідний email."role": "admin" і сподіватиметься на найкраще.«Просто використовуй ORM» не захищає тебе, якщо ти пишеш сирі запити для продуктивності. А сирі запити для продуктивності рано чи пізно пишуть усі.
// ВРАЗЛИВИЙ — конкатенація рядків
const query = `SELECT * FROM users WHERE email = '${email}'`;
// БЕЗПЕЧНИЙ — параметризований запит
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);З Prisma ти здебільшого в безпеці — але $queryRaw все одно може вкусити:
// ВРАЗЛИВИЙ — template literal у $queryRaw
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// БЕЗПЕЧНИЙ — використання Prisma.sql для параметризації
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);MongoDB не використовує SQL, але вона не захищена від ін'єкцій. Якщо ти передаєш несанітизований вхід користувача як об'єкт запиту, все йде не так:
// ВРАЗЛИВИЙ — якщо req.body.username є { "$gt": "" }
// це повертає першого користувача в колекції
const user = await db.collection("users").findOne({
username: req.body.username,
});
// БЕЗПЕЧНИЙ — явне перетворення в рядок
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// КРАЩЕ — спочатку валідація через Zod
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});Виправлення просте: валідуй типи вхідних даних перед тим, як вони потраплять до драйвера бази даних. Якщо username має бути рядком, підтверди, що це рядок.
Якщо твій API обслуговує файли або читає зі шляху, що містить вхідні дані користувача, path traversal зіпсує тобі тиждень:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// Нормалізуємо та розв'язуємо до абсолютного шляху
const resolved = path.resolve(ALLOWED_DIR, userInput);
// Критично: перевірити, що розв'язаний шлях все ще в межах дозволеної директорії
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Access denied");
}
// Перевірити, що файл дійсно існує
await access(resolved, constants.R_OK);
return resolved;
}
// Без цієї перевірки:
// GET /api/files?name=../../../etc/passwd
// розв'язується в /etc/passwdПатерн path.resolve + startsWith — це правильний підхід. Не намагайся видаляти ../ вручну — є занадто багато трюків з кодуванням (..%2F, ..%252F, ....//), які обійдуть твій regex.
Без rate limiting твій API — це шведський стіл «їж скільки хочеш» для ботів. Brute force атаки, credential stuffing, вичерпання ресурсів — rate limiting є першим захистом проти всього цього.
Token bucket: У тебе є відро, що вміщує N токенів. Кожен запит коштує один токен. Токени поповнюються з фіксованою швидкістю. Якщо відро порожнє, запит відхиляється. Це дозволяє сплески — якщо відро повне, ти можеш зробити N запитів миттєво.
Sliding window: Підрахунок запитів у рухомому часовому вікні. Більш передбачуваний, складніше пробити сплеском.
Я використовую sliding window для більшості речей, тому що поведінку легше зрозуміти та пояснити команді:
import { Redis } from "ioredis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
async function slidingWindowRateLimit(
redis: Redis,
key: string,
limit: number,
windowMs: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = redis.multi();
// Видаляємо записи поза вікном
multi.zremrangebyscore(key, 0, windowStart);
// Підраховуємо записи у вікні
multi.zcard(key);
// Додаємо поточний запит (видалимо його, якщо перевищено ліміт)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// Встановлюємо час життя ключа
multi.pexpire(key, windowMs);
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const count = results[1][1] as number;
if (count >= limit) {
// Перевищено ліміт — видаляємо запис, який щойно додали
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}Одного глобального rate limit недостатньо. Різні ендпоінти мають різні профілі ризику:
interface RateLimitConfig {
window: number;
max: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// Auth ендпоінти — жорсткі ліміти, ціль brute force
"POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
"POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
"POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
// Читання даних — більш щедрі
"GET:/api/users": { window: 60 * 1000, max: 100 },
"GET:/api/products": { window: 60 * 1000, max: 200 },
// Запис даних — помірні
"POST:/api/posts": { window: 60 * 1000, max: 10 },
"PUT:/api/posts": { window: 60 * 1000, max: 30 },
// Глобальний запасний варіант
"*": { window: 60 * 1000, max: 60 },
};
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
const identifier = req.user?.id ?? getClientIp(req);
const endpoint = `${req.method}:${req.path}`;
return `ratelimit:${identifier}:${endpoint}`;
}Зверни увагу: автентифіковані користувачі обмежуються за ID користувача, а не за IP. Це важливо, тому що багато легітимних користувачів мають спільні IP (корпоративні мережі, VPN, мобільні оператори). Якщо ти обмежуєш лише за IP, ти заблокуєш цілі офіси.
Завжди повідомляй клієнту, що відбувається:
function setRateLimitHeaders(
res: Response,
result: RateLimitResult,
limit: number
): void {
res.set({
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": result.remaining.toString(),
"X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
"Retry-After": result.allowed
? undefined
: Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
});
if (!result.allowed) {
res.status(429).json({
error: "Too many requests",
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
});
}
}CORS — це, мабуть, найбільш неправильно зрозумілий механізм безпеки у веб-розробці. Половина відповідей на Stack Overflow щодо CORS — це «просто встанови Access-Control-Allow-Origin: * і воно працює.» Технічно це правда. А ще так ти відкриваєш свій API для кожного зловмисного сайту в інтернеті.
CORS — це механізм браузера. Він повідомляє браузеру, чи дозволено JavaScript з Origin A читати відповідь від Origin B. І все.
Чого CORS не робить:
Що CORS робить:
// НЕБЕЗПЕЧНО — дозволяє будь-якому сайту читати відповіді твого API
app.use(cors({ origin: "*" }));
// ТЕАЖ НЕБЕЗПЕЧНО — це поширений "динамічний" підхід, який є просто * з додатковими кроками
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // Дозволяє все
},
})
);Проблема з * полягає в тому, що він робить відповіді твого API читабельними для будь-якого JavaScript на будь-якій сторінці. Якщо твій API повертає дані користувача і користувач автентифікований через cookies, будь-який сайт, який відвідує користувач, може прочитати ці дані.
Ще гірше: Access-Control-Allow-Origin: * не може поєднуватися з credentials: true. Тож якщо тобі потрібні cookies (для автентифікації), ти буквально не можеш використовувати wildcard. Але я бачив, як люди намагаються обійти це, відзеркалюючи заголовок Origin назад — що еквівалентно * з credentials, найгірше з обох світів.
import cors from "cors";
const ALLOWED_ORIGINS = new Set([
"https://yourapp.com",
"https://www.yourapp.com",
"https://admin.yourapp.com",
]);
if (process.env.NODE_ENV === "development") {
ALLOWED_ORIGINS.add("http://localhost:3000");
ALLOWED_ORIGINS.add("http://localhost:5173");
}
app.use(
cors({
origin: (origin, callback) => {
// Дозволити запити без origin (мобільні додатки, curl, server-to-server)
if (!origin) {
return callback(null, true);
}
if (ALLOWED_ORIGINS.has(origin)) {
return callback(null, origin);
}
callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, // Дозволити cookies
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // Кешувати preflight на 24 години
})
);Ключові рішення:
yourapp.com може збігтися з evilyourapp.com, якщо твій regex не заякорений правильно.credentials: true, тому що ми використовуємо httpOnly cookies для refresh token.maxAge: 86400 — Preflight запити (OPTIONS) додають затримку. Кажучи браузеру кешувати результат CORS на 24 години, ми зменшуємо непотрібні round trip.exposedHeaders — За замовчуванням браузер показує JavaScript лише кілька «простих» заголовків відповіді. Якщо ти хочеш, щоб клієнт читав твої заголовки rate limit, ти повинен явно їх виставити.Коли запит не є «простим» (він використовує нестандартний заголовок, нестандартний метод або нестандартний тип контенту), браузер спочатку надсилає OPTIONS запит, щоб попросити дозвіл. Це preflight.
Якщо твоя конфігурація CORS не обробляє OPTIONS, preflight запити будуть провалюватися, і фактичний запит ніколи не буде надісланий. Більшість бібліотек CORS обробляють це автоматично, але якщо ти використовуєш фреймворк, який цього не робить, тобі потрібно обробити це:
// Ручна обробка preflight (більшість фреймворків роблять це за тебе)
app.options("*", (req, res) => {
res.set({
"Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
});
res.status(204).end();
});Заголовки безпеки — це найдешевше покращення безпеки, яке ти можеш зробити. Це заголовки відповіді, що повідомляють браузеру ввімкнути функції безпеки. Більшість із них — це один рядок конфігурації, і вони захищають від цілих класів атак.
import helmet from "helmet";
// Один рядок. Це найшвидший виграш безпеки в будь-якому Express-додатку.
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Потрібно для багатьох CSS-in-JS рішень
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // 1 рік
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);Що робить кожен заголовок:
Content-Security-Policy (CSP) — Найпотужніший заголовок безпеки. Він повідомляє браузеру, які саме джерела дозволені для скриптів, стилів, зображень, шрифтів тощо. Якщо атакуючий впровадить тег <script>, що завантажується з evil.com, CSP його заблокує. Це найефективніший захист проти XSS.
Strict-Transport-Security (HSTS) — Повідомляє браузеру завжди використовувати HTTPS, навіть якщо користувач вводить http://. Директива preload дозволяє додати твій домен до вбудованого списку HSTS браузера, тому навіть перший запит примусово через HTTPS.
X-Frame-Options — Запобігає вбудовуванню твого сайту в iframe. Це зупиняє clickjacking-атаки, де атакуючий накладає невидимі елементи поверх твоєї сторінки. Helmet встановлює це на SAMEORIGIN за замовчуванням. Сучасна заміна — frame-ancestors у CSP.
X-Content-Type-Options: nosniff — Запобігає тому, щоб браузер вгадував (sniffing) MIME-тип відповіді. Без цього, якщо ти обслуговуєш файл із неправильним Content-Type, браузер може виконати його як JavaScript.
Referrer-Policy — Контролює, скільки інформації URL надсилається в заголовку Referer. strict-origin-when-cross-origin надсилає повний URL для same-origin запитів, але лише origin для cross-origin запитів. Це запобігає витіканню чутливих URL-параметрів до третіх сторін.
Після деплою перевір свій результат на securityheaders.com. Цільсь на рейтинг A+. Це займає близько п'яти хвилин конфігурації.
Ти також можеш перевірити заголовки програмно:
import { describe, it, expect } from "vitest";
describe("Security headers", () => {
it("should include all required security headers", async () => {
const response = await fetch("https://api.yourapp.com/health");
expect(response.headers.get("strict-transport-security")).toBeTruthy();
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(response.headers.get("content-security-policy")).toBeTruthy();
expect(response.headers.get("referrer-policy")).toBeTruthy();
expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet видаляє це
});
});Перевірка x-powered-by — тонка, але важлива. Express за замовчуванням встановлює X-Powered-By: Express, повідомляючи атакуючим, який саме фреймворк ти використовуєш. Helmet видаляє це.
Це мало б бути очевидним, але я все ще бачу це в pull request: API-ключі, паролі бази даних та JWT-секрети, зашиті в сирцевих файлах. Або закомічені у файлах .env, яких не було в .gitignore. Як тільки щось потрапляє в історію git, воно залишається там назавжди, навіть якщо ти видалиш файл у наступному коміті.
Ніколи не комітити секрети в git. Ні в коді, ні в .env, ні в конфігураційних файлах, ні у файлах Docker Compose, ні в коментарях «тільки для тестування».
Використовуй .env.example як шаблон. Він документує, які змінні оточення потрібні, без фактичних значень:
# .env.example — комітити це
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
# .env — НІКОЛИ не комітити це
# Вказано у .gitignoreimport { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
export type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Invalid environment variables:");
console.error(result.error.format());
process.exit(1); // Не стартуй з поганою конфігурацією
}
return result.data;
}
export const env = validateEnv();Для продакшн-систем використовуй повноцінний менеджер секретів:
Патерн однаковий незалежно від того, який ти використовуєш: застосунок отримує секрети при запуску з менеджера секретів, а не зі змінних оточення.
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // Лише активний ключ підписує нові токени
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // Повертає всі валідні ключі
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // Спробуй наступний ключ
}
}
throw new ApiError(401, "Invalid token");
}
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
const activeKey = getActiveSigningKey();
return jwt.sign(payload, activeKey.secret, {
algorithm: "HS256",
expiresIn: "15m",
keyid: activeKey.id, // Включити ID ключа в header
});
}OWASP API Security Top 10 — це галузевий стандартний список вразливостей API. Він періодично оновлюється, і кожен пункт у списку — це щось, що я бачив у реальних кодових базах. Дозволь пройтися по кожному.
Найпоширеніша вразливість API. Користувач автентифікований, але API не перевіряє, чи має він доступ до конкретного об'єкта, який запитує.
// ВРАЗЛИВИЙ — будь-який автентифікований користувач може отримати доступ до даних будь-якого користувача
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// ВИПРАВЛЕНИЙ — перевірка, що користувач отримує доступ до власних даних (або є адміном)
app.get("/api/users/:id", authenticate, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Access denied" });
}
const user = await db.users.findById(req.params.id);
return res.json(user);
});Вразлива версія зустрічається повсюди. Вона проходить кожну перевірку автентифікації — у користувача валідний токен — але не перевіряє, чи авторизований він для доступу до цього конкретного ресурсу. Зміни ID у URL, і ти отримаєш дані іншої людини.
Слабкі механізми входу, відсутній MFA, токени, що ніколи не закінчуються, паролі, збережені у відкритому вигляді. Це охоплює рівень автентифікації як такий.
Виправлення — це все, що ми обговорювали в розділі автентифікації: суворі вимоги до паролів, bcrypt із достатньою кількістю раундів, короткоживучі access token, ротація refresh token, блокування акаунту після невдалих спроб.
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 хвилин
async function handleLogin(email: string, password: string): Promise<AuthResult> {
const lockoutKey = `lockout:${email}`;
const attempts = await redis.get(lockoutKey);
if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
const ttl = await redis.pttl(lockoutKey);
throw new ApiError(
429,
`Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
);
}
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Збільшуємо кількість невдалих спроб
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// Одне й те саме повідомлення про помилку для обох випадків — не розкривай, чи існує email
throw new ApiError(401, "Invalid email or password");
}
// Скидаємо невдалі спроби при успішному вході
await redis.del(lockoutKey);
return generateTokens(user);
}Коментар про «одне й те саме повідомлення про помилку» важливий. Якщо твій API повертає «user not found» для невалідних email і «wrong password» для валідних email із неправильними паролями, ти повідомляєш атакуючому, які email існують у твоїй системі.
Повернення більше даних, ніж потрібно, або дозвіл користувачам змінювати властивості, яких вони не повинні.
// ВРАЗЛИВИЙ — повертає весь об'єкт користувача, включно з внутрішніми полями
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
// Відповідь включає: passwordHash, internalNotes, billingId, ...
});
// ВИПРАВЛЕНИЙ — явний allowlist полів, що повертаються
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json({
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt,
});
});Ніколи не повертай цілі об'єкти бази даних. Завжди обирай поля, які хочеш виставити. Це стосується і запису — не розкидай усе тіло запиту у свій update-запит:
// ВРАЗЛИВИЙ — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
await db.users.update(req.params.id, req.body);
// Атакуючий надсилає: { "role": "admin", "verified": true }
});
// ВИПРАВЛЕНИЙ — обирай дозволені поля
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
avatar: z.string().url().optional(),
});
app.put("/api/users/:id", authenticate, async (req, res) => {
const data = UpdateUserSchema.parse(req.body);
await db.users.update(req.params.id, data);
});Твій API — це ресурс. CPU, пам'ять, пропускна здатність, з'єднання з базою даних — все це кінцеве. Без обмежень один клієнт може вичерпати їх усі.
Це виходить за межі rate limiting. Це включає:
// Обмеження розміру тіла запиту
app.use(express.json({ limit: "1mb" }));
// Обмеження складності запитів
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_PAGE_SIZE)
.default(DEFAULT_PAGE_SIZE),
});
// Обмеження розміру завантажуваних файлів
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5 МБ
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Invalid file type"));
}
},
});
// Таймаут для довготривалих запитів
app.use((req, res, next) => {
res.setTimeout(30000, () => {
res.status(408).json({ error: "Request timeout" });
});
next();
});Відрізняється від BOLA. Це про доступ до функцій (ендпоінтів), до яких ти не повинен мати доступу, а не до об'єктів. Класичний приклад: звичайний користувач виявляє адміністративні ендпоінти.
// Middleware, що перевіряє доступ на основі ролей
function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!allowedRoles.includes(req.user.role)) {
// Логуємо спробу — це може бути атака
logger.warn("Unauthorized access attempt", {
userId: req.user.id,
role: req.user.role,
requiredRoles: allowedRoles,
endpoint: `${req.method} ${req.path}`,
ip: req.ip,
});
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
// Застосування до маршрутів
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);Не покладайся на приховування ендпоінтів. «Безпека через невідомість» — це не безпека. Навіть якщо URL адмін-панелі ніде не лінкується, хтось знайде /api/admin/users через fuzzing.
Автоматизоване зловживання легітимною бізнес-функціональністю. Подумай: боти, що скуповують товари з обмеженим запасом, автоматичне створення акаунтів для спаму, скрейпінг цін на товари.
Заходи протидії залежать від контексту: CAPTCHA, фінгерпринтинг пристроїв, поведінковий аналіз, step-up автентифікація для чутливих операцій. Тут немає універсального фрагмента коду.
Якщо твій API отримує URL від користувача (webhooks, URL аватарів, попередній перегляд посилань), атакуючий може змусити твій сервер запитувати внутрішні ресурси:
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
async function safeFetch(userProvidedUrl: string): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(userProvidedUrl);
} catch {
throw new ApiError(400, "Invalid URL");
}
// Дозволяти лише HTTP(S)
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "Only HTTP(S) URLs are allowed");
}
// Розв'язати hostname і перевірити, чи це приватний IP
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) {
throw new ApiError(400, "Internal addresses are not allowed");
}
}
// Тепер fetch з таймаутом та обмеженням розміру
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // Не слідувати за редиректами (вони можуть перенаправити на внутрішні IP)
});
return response;
} finally {
clearTimeout(timeout);
}
}Ключові деталі: спочатку розв'язуй DNS і перевіряй IP перед виконанням запиту. Блокуй редиректи — атакуючий може розмістити URL, що перенаправляє на http://169.254.169.254/ (ендпоінт метаданих AWS), щоб обійти твою перевірку на рівні URL.
Стандартні облікові дані залишені без змін, зайві HTTP-методи увімкнені, багатослівні повідомлення про помилки в продакшені, увімкнено listing директорій, неправильно налаштований CORS. Це категорія «ти забув замкнути двері».
// Не витікай stack trace у продакшені
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error("Unhandled error", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
if (process.env.NODE_ENV === "production") {
// Загальне повідомлення про помилку — не розкривай внутрішності
res.status(500).json({
error: "Internal server error",
requestId: req.id, // Включи ID запиту для дебагу
});
} else {
// У розробці показуй повну помилку
res.status(500).json({
error: err.message,
stack: err.stack,
});
}
});
// Вимкнути зайві HTTP-методи
app.use((req, res, next) => {
const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: "Method not allowed" });
}
next();
});Ти задеплоїв v2 API, але забув вимкнути v1. Або є ендпоінт /debug/, який був корисний під час розробки і все ще працює в продакшені. Або staging-сервер, що є публічно доступним із продакшн-даними.
Це не виправлення коду — це дисципліна ops. Підтримуй список усіх ендпоінтів API, усіх задеплоєних версій та всіх середовищ. Використовуй автоматичне сканування для пошуку відкритих сервісів. Вбивай те, що тобі не потрібно.
Твій API споживає API третіх сторін. Чи валідуєш ти їхні відповіді? Що станеться, якщо payload webhook від Stripe насправді від атакуючого?
import crypto from "crypto";
// Верифікація підписів Stripe webhook
function verifyStripeWebhook(
payload: string,
signature: string,
secret: string
): boolean {
const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
if (!timestamp || !expectedSig) return false;
// Відхиляємо старі мітки часу (запобігання replay-атакам)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) return false; // 5-хвилинна толерантність
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
);
}Завжди верифікуй підписи на webhooks. Завжди валідуй структуру відповідей API третіх сторін. Завжди встановлюй таймаути на вихідні запити. Ніколи не довіряй даним лише тому, що вони прийшли від «довіреного партнера».
Коли щось піде не так — а це станеться — аудиторські логи — це те, як ти з'ясуєш, що сталося. Але логування — це палиця з двома кінцями. Логуй занадто мало, і ти сліпий. Логуй занадто багато, і створюєш проблему конфіденційності.
interface AuditLogEntry {
timestamp: string;
action: string; // "user.login", "post.delete", "admin.role_change"
actor: {
id: string;
ip: string;
userAgent: string;
};
target: {
type: string; // "user", "post", "setting"
id: string;
};
result: "success" | "failure";
metadata: Record<string, unknown>; // Додатковий контекст
requestId: string; // Для кореляції з логами застосунку
}
async function auditLog(entry: AuditLogEntry): Promise<void> {
// Запис у окреме сховище даних лише для додавання
// Це НЕ повинна бути та сама база даних, яку використовує застосунок
await auditDb.collection("audit_logs").insertOne({
...entry,
timestamp: new Date().toISOString(),
});
// Для критичних дій також запис до незмінного зовнішнього логу
if (isCriticalAction(entry.action)) {
await externalLogger.send(entry);
}
}Логуй ці події:
Ніколи не логуй:
sk_live_...abc)function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = new Set([
"password",
"passwordHash",
"token",
"secret",
"apiKey",
"creditCard",
"ssn",
"authorization",
]);
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (sensitiveKeys.has(key.toLowerCase())) {
sanitized[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
} else {
sanitized[key] = value;
}
}
return sanitized;
}Якщо атакуючий отримає доступ до твоєї системи, одне з перших, що він зробить — змінить логи, щоб замести сліди. Логування, стійке до підробки, робить це виявленим:
import crypto from "crypto";
let previousHash = "GENESIS"; // Початковий хеш у ланцюжку
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
const content = JSON.stringify(entry) + previousHash;
const hash = crypto.createHash("sha256").update(content).digest("hex");
previousHash = hash;
return { ...entry, hash };
}
// Для верифікації цілісності ланцюжка:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
let expectedPreviousHash = "GENESIS";
for (const entry of entries) {
const { hash, ...rest } = entry;
const content = JSON.stringify(rest) + expectedPreviousHash;
const computedHash = crypto.createHash("sha256").update(content).digest("hex");
if (computedHash !== hash) {
return false; // Ланцюжок порушено — логи були підроблені
}
expectedPreviousHash = hash;
}
return true;
}Це та сама концепція, що й блокчейн — хеш кожного запису логу залежить від попереднього запису. Якщо хтось змінить або видалить запис, ланцюжок розривається.
Твій код може бути безпечним. Але що щодо 847 npm-пакетів у твоєму node_modules? Проблема ланцюга постачання реальна, і з роками вона стала гіршою.
# Запускай це в CI, провалюй білд на high/critical вразливостях
npm audit --audit-level=high
# Виправ те, що можна виправити автоматично
npm audit fix
# Подивись, що ти фактично підтягуєш
npm ls --allАле npm audit має обмеження. Він перевіряє лише базу даних консультативних повідомлень npm, і його оцінки серйозності не завжди точні. Додай додаткові інструменти:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-team"
labels:
- "dependencies"
# Групуй minor та patch оновлення для зменшення шуму PR
groups:
production-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"Завжди комітити свій package-lock.json (або pnpm-lock.yaml, або yarn.lock). Lockfile фіксує точні версії кожної залежності, включно з транзитивними. Без нього npm install може підтягнути іншу версію, ніж ту, яку ти тестував — і ця інша версія може бути скомпрометованою.
# У CI використовуй ci замість install — він суворо дотримується lockfile
npm cinpm ci провалюється, якщо lockfile не відповідає package.json, замість того, щоб тихо оновлювати його. Це ловить випадки, коли хтось змінив package.json, але забув оновити lockfile.
Перш ніж додавати залежність, запитай:
is-odd залежить від is-number, який залежить від kind-of. Це три пакети для того, що один рядок коду може зробити.// Тобі не потрібен пакет для цього:
const isEven = (n: number): boolean => n % 2 === 0;
// Або для цього:
const leftPad = (str: string, len: number, char = " "): string =>
str.padStart(len, char);
// Або для цього:
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;Це фактичний чеклист, який я використовую перед кожним продакшн-деплоєм. Він не вичерпний — безпека ніколи не «завершена» — але він ловить помилки, які мають найбільше значення.
| # | Перевірка | Критерій проходження | Пріоритет |
|---|---|---|---|
| 1 | Автентифікація | JWT верифіковані з явним алгоритмом, issuer та audience. Без alg: none. | Критичний |
| 2 | Термін дії токенів | Access token закінчуються за 15 хв або менше. Refresh token ротуються при використанні. | Критичний |
| 3 | Зберігання токенів | Refresh token у httpOnly secure cookies. Жодних токенів у localStorage. | Критичний |
| 4 | Авторизація на кожному ендпоінті | Кожен ендпоінт доступу до даних перевіряє дозволи на рівні об'єкта. BOLA протестовано. | Критичний |
| 5 | Валідація вхідних даних | Увесь вхід від користувача валідується через Zod або аналог. Жодного сирого req.body у запитах. | Критичний |
| 6 | SQL/NoSQL injection | Усі запити до БД використовують параметризовані запити або методи ORM. Жодної конкатенації рядків. | Критичний |
| 7 | Rate limiting | Auth ендпоінти: 5/15хв. Загальний API: 60/хв. Заголовки rate limit повертаються. | Високий |
| 8 | CORS | Явний allowlist origin. Без wildcard з credentials. Preflight кешується. | Високий |
| 9 | Заголовки безпеки | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy — все присутнє. | Високий |
| 10 | Обробка помилок | Продакшн-помилки повертають загальні повідомлення. Жодних stack trace, жодних SQL-помилок назовні. | Високий |
| 11 | Секрети | Жодних секретів у коді чи історії git. .env у .gitignore. Валідуються при старті. | Критичний |
| 12 | Залежності | npm audit чистий (без high/critical). Lockfile закомічений. npm ci у CI. | Високий |
| 13 | Тільки HTTPS | HSTS увімкнено з preload. HTTP перенаправляє на HTTPS. Прапорець secure cookie встановлено. | Критичний |
| 14 | Логування | Події автентифікації, відмови в доступі та мутації даних логуються. Жодного PII в логах. | Середній |
| 15 | Обмеження розміру запитів | Body parser обмежений (1 МБ за замовчуванням). Завантаження файлів обмежені. Пагінація запитів примусова. | Середній |
| 16 | Захист від SSRF | URL від користувачів валідуються. Приватні IP заблоковані. Редиректи вимкнені або валідуються. | Середній |
| 17 | Блокування акаунту | Невдалі спроби входу активують блокування після 5 спроб. Блокування логується. | Високий |
| 18 | Верифікація webhooks | Усі вхідні webhooks верифіковані підписами. Захист від replay через мітки часу. | Високий |
| 19 | Адмін-ендпоінти | Контроль доступу на основі ролей на всіх адміністративних маршрутах. Спроби логуються. | Критичний |
| 20 | Mass assignment | Update ендпоінти використовують Zod-схему з allowlist полів. Жодного розкидання сирого body. | Високий |
Я зберігаю це як шаблон GitHub issue. Перед тегуванням релізу хтось із команди повинен перевірити кожен рядок і підтвердити. Це не гламурно, але працює.
Безпека — це не фіча, яку додаєш наприкінці. Це не спринт, який ти робиш раз на рік. Це спосіб думати про кожен рядок коду, який ти пишеш.
Коли ти пишеш ендпоінт, думай: «Що, якщо хтось надішле дані, яких я не очікую?» Коли ти додаєш параметр, думай: «Що, якщо хтось змінить це на ID іншої людини?» Коли ти додаєш залежність, думай: «Що станеться, якщо цей пакет буде скомпрометований наступного вівторка?»
Ти не зловиш усе. Ніхто не ловить. Але проходження цього чеклиста — методично, перед кожним деплоєм — ловить те, що має найбільше значення. Легкі перемоги. Очевидні діри. Помилки, що перетворюють поганий день на витік даних.
Побудуй звичку. Проганяй чеклист. Деплой з впевненістю.