Перейти до вмісту
·30 хв читання

Найкращі практики безпеки API: Чеклист, який я проганяю на кожному проєкті

Автентифікація, авторизація, валідація вхідних даних, rate limiting, CORS, керування секретами та OWASP API Top 10. Що я перевіряю перед кожним продакшн-деплоєм.

Поділитися:X / TwitterLinkedIn

Я випускав API, які були повністю відкриті. Не зловмисно, не через лінощі — я просто не знав того, чого не знав. Ендпоінт, що повертав усі поля об'єкта користувача, включно з хешованими паролями. Rate limiter, який перевіряв лише IP-адреси, а отже будь-хто за проксі міг бомбити API. Реалізація JWT, де я забув перевірити claim iss, тому токени від зовсім іншого сервісу чудово працювали.

Кожна з цих помилок потрапила в продакшн. Кожну з них виявили — деякі я сам, деякі користувачі, одну — дослідник безпеки, який був досить люб'язний, щоб надіслати мені електронного листа замість того, щоб публікувати це в Twitter.

Цей пост — чеклист, який я побудував на основі тих помилок. Я проходжу його перед кожним продакшн-деплоєм. Не тому, що я параноїк, а тому, що я навчився: баги безпеки — це ті, що болять найбільше. Зламана кнопка дратує користувачів. Зламаний потік автентифікації витікає їхні дані.

Автентифікація vs Авторизація#

Ці два слова вживають як взаємозамінні на нарадах, у документації, навіть у коментарях до коду. Вони не одне й те саме.

Автентифікація відповідає: «Хто ти?» Це крок входу в систему. Ім'я користувача та пароль, потік OAuth, magic link — все, що підтверджує твою особу.

Авторизація відповідає: «Що тобі дозволено робити?» Це крок дозволів. Чи може цей користувач видалити цей ресурс? Чи може він отримати доступ до цього адміністративного ендпоінта? Чи може він прочитати дані іншого користувача?

Найпоширеніший баг безпеки, який я бачив у продакшн API — це не зламаний потік входу. Це відсутня перевірка авторизації. Користувач автентифікований — у нього є валідний токен — але API ніколи не перевіряє, чи дозволено йому виконувати дію, яку він запитує.

JWT: Анатомія та помилки, що мають значення#

JWT зустрічаються повсюди. Їх також повсюди неправильно розуміють. JWT має три частини, розділені крапками:

header.payload.signature

Header вказує, який алгоритм використовувався. Payload містить claims (ID користувача, ролі, термін дії). Signature доводить, що ніхто не підробив перші дві частини.

Ось правильна верифікація JWT у Node.js:

typescript
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");
  }
}

Декілька речей, на які слід звернути увагу:

  1. algorithms: ["HS256"] — Це критично важливо. Якщо ти не вкажеш алгоритм, атакуючий може надіслати токен із "alg": "none" у header і повністю обійти верифікацію. Це атака alg: none, і вона вражала реальні продакшн-системи.

  2. issuer та audience — Без них токен, створений для Сервісу A, працює на Сервісі B. Якщо ти запускаєш кілька сервісів із спільним секретом (чого не варто робити, але люди роблять), саме так відбувається міжсервісне зловживання токенами.

  3. Специфічна обробка помилок — Не повертай "invalid token" для кожної помилки. Розрізнення між простроченим та невалідним допомагає клієнту зрозуміти, чи потрібно оновити токен, чи повторно автентифікуватися.

Ротація Refresh Token#

Access token повинні бути короткоживучими — 15 хвилин є стандартом. Але ти не хочеш, щоб користувачі вводили свій пароль кожні 15 хвилин. Ось де на сцену виходять refresh token.

Патерн, який насправді працює в продакшені:

typescript
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 надсилаються автоматично), але атакуючий не може вкрасти сам токен. Це суттєва різниця.

typescript
// Встановлення безпечного 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 для типобезпечної валідації#

Zod — це найкраще, що сталося з валідацією вхідних даних у Node.js. Він дає тобі валідацію під час виконання з типами TypeScript безкоштовно:

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.
  • Enum для ролі, без admin — Mass assignment — одна з найстаріших вразливостей API. Якщо ти приймаєш роль із тіла запиту без валідації, хтось надішле "role": "admin" і сподіватиметься на найкраще.

SQL Injection не вирішена#

«Просто використовуй ORM» не захищає тебе, якщо ти пишеш сирі запити для продуктивності. А сирі запити для продуктивності рано чи пізно пишуть усі.

typescript
// ВРАЗЛИВИЙ — конкатенація рядків
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 все одно може вкусити:

typescript
// ВРАЗЛИВИЙ — 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}%`}`
);

NoSQL Injection#

MongoDB не використовує SQL, але вона не захищена від ін'єкцій. Якщо ти передаєш несанітизований вхід користувача як об'єкт запиту, все йде не так:

typescript
// ВРАЗЛИВИЙ — якщо 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 має бути рядком, підтверди, що це рядок.

Path Traversal#

Якщо твій API обслуговує файли або читає зі шляху, що містить вхідні дані користувача, path traversal зіпсує тобі тиждень:

typescript
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#

Без rate limiting твій API — це шведський стіл «їж скільки хочеш» для ботів. Brute force атаки, credential stuffing, вичерпання ресурсів — rate limiting є першим захистом проти всього цього.

Token Bucket vs Sliding Window#

Token bucket: У тебе є відро, що вміщує N токенів. Кожен запит коштує один токен. Токени поповнюються з фіксованою швидкістю. Якщо відро порожнє, запит відхиляється. Це дозволяє сплески — якщо відро повне, ти можеш зробити N запитів миттєво.

Sliding window: Підрахунок запитів у рухомому часовому вікні. Більш передбачуваний, складніше пробити сплеском.

Я використовую sliding window для більшості речей, тому що поведінку легше зрозуміти та пояснити команді:

typescript
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 Limits#

Одного глобального rate limit недостатньо. Різні ендпоінти мають різні профілі ризику:

typescript
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, ти заблокуєш цілі офіси.

Заголовки Rate Limit#

Завжди повідомляй клієнту, що відбувається:

typescript
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#

CORS — це, мабуть, найбільш неправильно зрозумілий механізм безпеки у веб-розробці. Половина відповідей на Stack Overflow щодо CORS — це «просто встанови Access-Control-Allow-Origin: * і воно працює.» Технічно це правда. А ще так ти відкриваєш свій API для кожного зловмисного сайту в інтернеті.

Що CORS насправді робить (і чого не робить)#

CORS — це механізм браузера. Він повідомляє браузеру, чи дозволено JavaScript з Origin A читати відповідь від Origin B. І все.

Чого CORS не робить:

  • Він не захищає твій API від curl, Postman або server-to-server запитів
  • Він не автентифікує запити
  • Він нічого не шифрує
  • Він не запобігає CSRF сам по собі (хоча допомагає в поєднанні з іншими механізмами)

Що CORS робить:

  • Він запобігає тому, щоб malicious-website.com робив fetch-запити до your-api.com і читав відповідь у браузері користувача
  • Він запобігає тому, щоб JavaScript атакуючого ексфільтрував дані через автентифіковану сесію жертви

Пастка Wildcard#

typescript
// НЕБЕЗПЕЧНО — дозволяє будь-якому сайту читати відповіді твого 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, найгірше з обох світів.

Правильна конфігурація#

typescript
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 години
  })
);

Ключові рішення:

  • Явний набір origin, а не regex. Regex хитрі — yourapp.com може збігтися з evilyourapp.com, якщо твій regex не заякорений правильно.
  • credentials: true, тому що ми використовуємо httpOnly cookies для refresh token.
  • maxAge: 86400 — Preflight запити (OPTIONS) додають затримку. Кажучи браузеру кешувати результат CORS на 24 години, ми зменшуємо непотрібні round trip.
  • exposedHeaders — За замовчуванням браузер показує JavaScript лише кілька «простих» заголовків відповіді. Якщо ти хочеш, щоб клієнт читав твої заголовки rate limit, ти повинен явно їх виставити.

Preflight запити#

Коли запит не є «простим» (він використовує нестандартний заголовок, нестандартний метод або нестандартний тип контенту), браузер спочатку надсилає OPTIONS запит, щоб попросити дозвіл. Це preflight.

Якщо твоя конфігурація CORS не обробляє OPTIONS, preflight запити будуть провалюватися, і фактичний запит ніколи не буде надісланий. Більшість бібліотек CORS обробляють це автоматично, але якщо ти використовуєш фреймворк, який цього не робить, тобі потрібно обробити це:

typescript
// Ручна обробка 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();
});

Заголовки безпеки#

Заголовки безпеки — це найдешевше покращення безпеки, яке ти можеш зробити. Це заголовки відповіді, що повідомляють браузеру ввімкнути функції безпеки. Більшість із них — це один рядок конфігурації, і вони захищають від цілих класів атак.

Заголовки, що мають значення#

typescript
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+. Це займає близько п'яти хвилин конфігурації.

Ти також можеш перевірити заголовки програмно:

typescript
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, воно залишається там назавжди, навіть якщо ти видалиш файл у наступному коміті.

Правила#

  1. Ніколи не комітити секрети в git. Ні в коді, ні в .env, ні в конфігураційних файлах, ні у файлах Docker Compose, ні в коментарях «тільки для тестування».

  2. Використовуй .env.example як шаблон. Він документує, які змінні оточення потрібні, без фактичних значень:

bash
# .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 — НІКОЛИ не комітити це
# Вказано у .gitignore
  1. Валідуй змінні оточення при запуску. Не чекай, поки запит потрапить до ендпоінта, якому потрібен URL бази даних. Падай швидко:
typescript
import { 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();
  1. Використовуй менеджер секретів у продакшені. Змінні оточення працюють для простих налаштувань, але мають обмеження: вони видимі в списках процесів, зберігаються в пам'яті і можуть витекти через логи помилок.

Для продакшн-систем використовуй повноцінний менеджер секретів:

  • AWS Secrets Manager або SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (якщо хочеш щось, що працює з усіма хмарами)

Патерн однаковий незалежно від того, який ти використовуєш: застосунок отримує секрети при запуску з менеджера секретів, а не зі змінних оточення.

  1. Ротуй секрети регулярно. Якщо ти використовуєш той самий JWT-секрет вже два роки, час ротувати. Реалізуй ротацію ключів: підтримуй кілька валідних ключів підпису одночасно, підписуй нові токени новим ключем, верифікуй як старим, так і новим, і виводь старий ключ з обігу після закінчення терміну дії всіх існуючих токенів.
typescript
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#

OWASP API Security Top 10 — це галузевий стандартний список вразливостей API. Він періодично оновлюється, і кожен пункт у списку — це щось, що я бачив у реальних кодових базах. Дозволь пройтися по кожному.

API1: Broken Object Level Authorization (BOLA)#

Найпоширеніша вразливість API. Користувач автентифікований, але API не перевіряє, чи має він доступ до конкретного об'єкта, який запитує.

typescript
// ВРАЗЛИВИЙ — будь-який автентифікований користувач може отримати доступ до даних будь-якого користувача
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, і ти отримаєш дані іншої людини.

API2: Broken Authentication#

Слабкі механізми входу, відсутній MFA, токени, що ніколи не закінчуються, паролі, збережені у відкритому вигляді. Це охоплює рівень автентифікації як такий.

Виправлення — це все, що ми обговорювали в розділі автентифікації: суворі вимоги до паролів, bcrypt із достатньою кількістю раундів, короткоживучі access token, ротація refresh token, блокування акаунту після невдалих спроб.

typescript
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 існують у твоїй системі.

API3: Broken Object Property Level Authorization#

Повернення більше даних, ніж потрібно, або дозвіл користувачам змінювати властивості, яких вони не повинні.

typescript
// ВРАЗЛИВИЙ — повертає весь об'єкт користувача, включно з внутрішніми полями
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-запит:

typescript
// ВРАЗЛИВИЙ — 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);
});

API4: Unrestricted Resource Consumption#

Твій API — це ресурс. CPU, пам'ять, пропускна здатність, з'єднання з базою даних — все це кінцеве. Без обмежень один клієнт може вичерпати їх усі.

Це виходить за межі rate limiting. Це включає:

typescript
// Обмеження розміру тіла запиту
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();
});

API5: Broken Function Level Authorization#

Відрізняється від BOLA. Це про доступ до функцій (ендпоінтів), до яких ти не повинен мати доступу, а не до об'єктів. Класичний приклад: звичайний користувач виявляє адміністративні ендпоінти.

typescript
// 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.

API6: Unrestricted Access to Sensitive Business Flows#

Автоматизоване зловживання легітимною бізнес-функціональністю. Подумай: боти, що скуповують товари з обмеженим запасом, автоматичне створення акаунтів для спаму, скрейпінг цін на товари.

Заходи протидії залежать від контексту: CAPTCHA, фінгерпринтинг пристроїв, поведінковий аналіз, step-up автентифікація для чутливих операцій. Тут немає універсального фрагмента коду.

API7: Server Side Request Forgery (SSRF)#

Якщо твій API отримує URL від користувача (webhooks, URL аватарів, попередній перегляд посилань), атакуючий може змусити твій сервер запитувати внутрішні ресурси:

typescript
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.

API8: Security Misconfiguration#

Стандартні облікові дані залишені без змін, зайві HTTP-методи увімкнені, багатослівні повідомлення про помилки в продакшені, увімкнено listing директорій, неправильно налаштований CORS. Це категорія «ти забув замкнути двері».

typescript
// Не витікай 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();
});

API9: Improper Inventory Management#

Ти задеплоїв v2 API, але забув вимкнути v1. Або є ендпоінт /debug/, який був корисний під час розробки і все ще працює в продакшені. Або staging-сервер, що є публічно доступним із продакшн-даними.

Це не виправлення коду — це дисципліна ops. Підтримуй список усіх ендпоінтів API, усіх задеплоєних версій та всіх середовищ. Використовуй автоматичне сканування для пошуку відкритих сервісів. Вбивай те, що тобі не потрібно.

API10: Unsafe Consumption of APIs#

Твій API споживає API третіх сторін. Чи валідуєш ти їхні відповіді? Що станеться, якщо payload webhook від Stripe насправді від атакуючого?

typescript
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 третіх сторін. Завжди встановлюй таймаути на вихідні запити. Ніколи не довіряй даним лише тому, що вони прийшли від «довіреного партнера».

Аудиторське логування#

Коли щось піде не так — а це станеться — аудиторські логи — це те, як ти з'ясуєш, що сталося. Але логування — це палиця з двома кінцями. Логуй занадто мало, і ти сліпий. Логуй занадто багато, і створюєш проблему конфіденційності.

Що логувати#

typescript
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);
  }
}

Логуй ці події:

  • Автентифікація: входи, виходи, невдалі спроби, оновлення токенів
  • Авторизація: події відмови в доступі (часто є індикаторами атаки)
  • Модифікація даних: створення, оновлення, видалення — хто що змінив і коли
  • Адміністративні дії: зміни ролей, управління користувачами, зміни конфігурації
  • Події безпеки: спрацювання rate limit, порушення CORS, некоректні запити

Чого НЕ логувати#

Ніколи не логуй:

  • Паролі (навіть хешовані — хеш є обліковими даними)
  • Повні номери кредитних карток (логуй лише останні 4 цифри)
  • Номери соціального страхування або державні ідентифікатори
  • API-ключі або токени (логуй максимум префікс: sk_live_...abc)
  • Персональну медичну інформацію
  • Повні тіла запитів, які можуть містити PII
typescript
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;
}

Логи, стійкі до підробки#

Якщо атакуючий отримає доступ до твоєї системи, одне з перших, що він зробить — змінить логи, щоб замести сліди. Логування, стійке до підробки, робить це виявленим:

typescript
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? Проблема ланцюга постачання реальна, і з роками вона стала гіршою.

npm audit — це мінімум#

bash
# Запускай це в CI, провалюй білд на high/critical вразливостях
npm audit --audit-level=high
 
# Виправ те, що можна виправити автоматично
npm audit fix
 
# Подивись, що ти фактично підтягуєш
npm ls --all

Але npm audit має обмеження. Він перевіряє лише базу даних консультативних повідомлень npm, і його оцінки серйозності не завжди точні. Додай додаткові інструменти:

Автоматичне сканування залежностей#

yaml
# .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"

Lockfile — це інструмент безпеки#

Завжди комітити свій package-lock.json (або pnpm-lock.yaml, або yarn.lock). Lockfile фіксує точні версії кожної залежності, включно з транзитивними. Без нього npm install може підтягнути іншу версію, ніж ту, яку ти тестував — і ця інша версія може бути скомпрометованою.

bash
# У CI використовуй ci замість install — він суворо дотримується lockfile
npm ci

npm ci провалюється, якщо lockfile не відповідає package.json, замість того, щоб тихо оновлювати його. Це ловить випадки, коли хтось змінив package.json, але забув оновити lockfile.

Оцінюй перед встановленням#

Перш ніж додавати залежність, запитай:

  1. Чи мені це дійсно потрібно? Чи можу я написати це в 20 рядках замість додавання пакета?
  2. Скільки у нього завантажень? Мала кількість завантажень не обов'язково погана, але це означає менше очей, що переглядають код.
  3. Коли його востаннє оновлювали? Пакет, що не оновлювався 3 роки, може мати незакриті вразливості.
  4. Скільки залежностей він підтягує? is-odd залежить від is-number, який залежить від kind-of. Це три пакети для того, що один рядок коду може зробити.
  5. Хто його підтримує? Один мейнтейнер — це одна точка компрометації.
typescript
// Тобі не потрібен пакет для цього:
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 у запитах.Критичний
6SQL/NoSQL injectionУсі запити до БД використовують параметризовані запити або методи ORM. Жодної конкатенації рядків.Критичний
7Rate limitingAuth ендпоінти: 5/15хв. Загальний API: 60/хв. Заголовки rate limit повертаються.Високий
8CORSЯвний 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Тільки HTTPSHSTS увімкнено з preload. HTTP перенаправляє на HTTPS. Прапорець secure cookie встановлено.Критичний
14ЛогуванняПодії автентифікації, відмови в доступі та мутації даних логуються. Жодного PII в логах.Середній
15Обмеження розміру запитівBody parser обмежений (1 МБ за замовчуванням). Завантаження файлів обмежені. Пагінація запитів примусова.Середній
16Захист від SSRFURL від користувачів валідуються. Приватні IP заблоковані. Редиректи вимкнені або валідуються.Середній
17Блокування акаунтуНевдалі спроби входу активують блокування після 5 спроб. Блокування логується.Високий
18Верифікація webhooksУсі вхідні webhooks верифіковані підписами. Захист від replay через мітки часу.Високий
19Адмін-ендпоінтиКонтроль доступу на основі ролей на всіх адміністративних маршрутах. Спроби логуються.Критичний
20Mass assignmentUpdate ендпоінти використовують Zod-схему з allowlist полів. Жодного розкидання сирого body.Високий

Я зберігаю це як шаблон GitHub issue. Перед тегуванням релізу хтось із команди повинен перевірити кожен рядок і підтвердити. Це не гламурно, але працює.

Зміна мислення#

Безпека — це не фіча, яку додаєш наприкінці. Це не спринт, який ти робиш раз на рік. Це спосіб думати про кожен рядок коду, який ти пишеш.

Коли ти пишеш ендпоінт, думай: «Що, якщо хтось надішле дані, яких я не очікую?» Коли ти додаєш параметр, думай: «Що, якщо хтось змінить це на ID іншої людини?» Коли ти додаєш залежність, думай: «Що станеться, якщо цей пакет буде скомпрометований наступного вівторка?»

Ти не зловиш усе. Ніхто не ловить. Але проходження цього чеклиста — методично, перед кожним деплоєм — ловить те, що має найбільше значення. Легкі перемоги. Очевидні діри. Помилки, що перетворюють поганий день на витік даних.

Побудуй звичку. Проганяй чеклист. Деплой з впевненістю.

Схожі записи