Перейти к содержимому
·30 мин чтения

Лучшие практики безопасности API: чек-лист, который я проверяю на каждом проекте

Аутентификация, авторизация, валидация входных данных, ограничение частоты запросов, CORS, управление секретами и OWASP API Top 10. Что я проверяю перед каждым продакшн-деплоем.

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

Я выпускал API, которые были совершенно незащищены. Не злонамеренно, не по лени — я просто не знал того, чего не знал. Эндпоинт, который возвращал все поля объекта пользователя, включая хешированные пароли. Ограничитель частоты запросов, который проверял только IP-адреса, что означало: любой, кто за прокси, мог долбить API без ограничений. Реализация JWT, где я забыл проверить claim iss, и токены от совершенно другого сервиса прекрасно работали.

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

Этот пост — чек-лист, который я составил на основе этих ошибок. Я прохожу по нему перед каждым продакшн-деплоем. Не потому что я параноик, а потому что я усвоил: баги безопасности причиняют больше всего вреда. Сломанная кнопка раздражает пользователей. Сломанная аутентификация сливает их данные.

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

Эти два слова используют как синонимы на митингах, в документации и даже в комментариях к коду. Они означают не одно и то же.

Аутентификация отвечает на вопрос: «Кто ты?» Это этап входа в систему. Имя пользователя и пароль, OAuth-флоу, magic link — всё, что подтверждает вашу личность.

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

Самый распространённый баг безопасности, который я видел в продакшн API — это не сломанный процесс логина. Это отсутствующая проверка авторизации. Пользователь аутентифицирован — у него валидный токен — но API никогда не проверяет, разрешено ли ему выполнять запрашиваемое действие.

JWT: анатомия и ошибки, которые имеют значение#

JWT повсюду. И повсюду их понимают неправильно. JWT состоит из трёх частей, разделённых точками:

header.payload.signature

Заголовок указывает, какой алгоритм использовался. Полезная нагрузка содержит claims (ID пользователя, роли, время истечения). Подпись доказывает, что никто не подделал первые две части.

Вот правильная верификация 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" в заголовке и полностью обойти верификацию. Это атака alg: none, и она затронула реальные продакшн-системы.

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

  3. Раздельная обработка ошибок — Не возвращайте "invalid token" при каждом сбое. Различие между истёкшим и невалидным токеном помогает клиенту понять, нужно ли обновить токен или пройти аутентификацию заново.

Ротация Refresh-токенов#

Access-токены должны быть короткоживущими — 15 минут — это стандарт. Но вы не хотите, чтобы пользователи вводили пароль каждые 15 минут. Для этого и существуют refresh-токены.

Паттерн, который реально работает в продакшне:

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-токен с тем же семейством
  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-токен,
  // мы уничтожаем каждый токен в семействе, вынуждая повторную аутентификацию.
  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-токен принадлежит семейству (создаётся при логине). При ротации новый токен наследует семейство. Если злоумышленник воспроизводит старый refresh-токен, вы обнаруживаете повторное использование и уничтожаете всё семейство. Легитимный пользователь будет разлогинен, но злоумышленник не получит доступа.

Эта дискуссия идёт годами, и ответ однозначен: httpOnly cookies для refresh-токенов, всегда.

localStorage доступен любому JavaScript, выполняющемуся на вашей странице. Если у вас есть хоть одна XSS-уязвимость — а при масштабе рано или поздно будет — злоумышленник может прочитать токен и вывести его наружу. Игра окончена.

httpOnly cookies недоступны для JavaScript. Точка. XSS-уязвимость всё ещё может выполнять запросы от имени пользователя (потому что cookies отправляются автоматически), но злоумышленник не сможет украсть сам токен. Это существенная разница.

typescript
// Установка защищённой cookie с refresh-токеном
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Недоступен через JavaScript
    secure: true,       // Только HTTPS
    sameSite: "strict", // Нет межсайтовых запросов
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 дней
    path: "/api/auth",  // Отправляется только к эндпоинтам аутентификации
  });
}

path: "/api/auth" — деталь, которую большинство упускает. По умолчанию cookies отправляются к каждому эндпоинту на вашем домене. Вашему refresh-токену не нужно отправляться к /api/users или /api/products. Ограничьте путь — уменьшите поверхность атаки.

Для access-токенов я храню их в памяти (JavaScript-переменная). Не в localStorage, не в sessionStorage, не в cookie. В памяти. Они короткоживущие (15 минут), и при обновлении страницы клиент незаметно обращается к эндпоинту обновления за новым токеном. Да, это лишний запрос при загрузке страницы. Оно того стоит.

Валидация входных данных: никогда не доверяй клиенту#

Клиент — не ваш друг. Клиент — это незнакомец, который вошёл в ваш дом и сказал «Мне разрешено здесь находиться». Вы всё равно проверяете его удостоверение.

Каждый фрагмент данных, приходящий извне вашего сервера — тело запроса, query-параметры, 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") // Предотвращает DoS через bcrypt
    .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-инъекции не решены#

«Просто используйте 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
// УЯЗВИМО — шаблонная строка в $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-инъекции#

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, ....//), которые обойдут ваше регулярное выражение.

Ограничение частоты запросов (Rate Limiting)#

Без ограничения частоты ваш API — это шведский стол для ботов. Брутфорс-атаки, 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,
  };
}

Многоуровневые лимиты#

Одного глобального лимита недостаточно. У разных эндпоинтов разные профили риска:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Эндпоинты аутентификации — строгие лимиты, цели для брутфорса
  "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, вы заблокируете целые офисы.

Заголовки ограничения частоты#

Всегда информируйте клиента о происходящем:

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 с Источника A прочитать ответ от Источника B. Вот и всё.

Чего CORS не делает:

  • Не защищает ваш API от curl, Postman или межсерверных запросов
  • Не аутентифицирует запросы
  • Не шифрует ничего
  • Не предотвращает CSRF сам по себе (хотя помогает в сочетании с другими механизмами)

Что CORS делает:

  • Запрещает malicious-website.com выполнять fetch-запросы к your-api.com и читать ответ в браузере пользователя
  • Предотвращает экфильтрацию данных JavaScript злоумышленника через аутентифицированную сессию жертвы

Ловушка подстановочного символа#

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 (для аутентификации), вы буквально не можете использовать подстановочный символ. Но я видел, как люди пытаются обойти это, отражая заголовок 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, межсерверные)
      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, а не регулярное выражение. Регулярные выражения коварны — yourapp.com может совпасть с evilyourapp.com, если ваш regex неправильно закреплён.
  • credentials: true, потому что мы используем httpOnly cookies для refresh-токенов.
  • 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-файлах, ни в комментариях «just for testing».

  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 ключа в заголовок
  });
}

OWASP API Security Top 10#

OWASP API Security Top 10 — это отраслевой стандартный список уязвимостей API. Он периодически обновляется, и каждый пункт в списке — это то, что я видел в реальных кодовых базах. Давайте разберём каждый из них.

API1: Нарушение авторизации на уровне объектов (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: Нарушение аутентификации#

Слабые механизмы входа, отсутствие MFA, токены, которые никогда не истекают, пароли, хранящиеся в открытом тексте. Это охватывает сам уровень аутентификации.

Решение — всё, что мы обсудили в разделе аутентификации: строгие требования к паролям, bcrypt с достаточным количеством раундов, короткоживущие access-токены, ротация refresh-токенов, блокировка аккаунта после неудачных попыток.

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: Нарушение авторизации на уровне свойств объекта#

Возврат больше данных, чем необходимо, или разрешение пользователям изменять свойства, которые они не должны.

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, ...
});
 
// ИСПРАВЛЕНО — явный список разрешённых полей
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,
  });
});

Никогда не возвращайте целые объекты базы данных. Всегда выбирайте поля, которые хотите раскрыть. Это относится и к записи — не разворачивайте всё тело запроса в запрос обновления:

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: Неограниченное потребление ресурсов#

Ваш 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: Нарушение авторизации на уровне функций#

Отличается от 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 через фаззинг.

API6: Неограниченный доступ к чувствительным бизнес-потокам#

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

Средства защиты зависят от контекста: CAPTCHA, фингерпринтинг устройств, поведенческий анализ, усиленная аутентификация для чувствительных операций. Универсального фрагмента кода здесь нет.

API7: Подделка запросов на стороне сервера (SSRF)#

Если ваш API загружает URL, предоставленные пользователем (вебхуки, 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");
  }
 
  // Разрешаем имя хоста и проверяем, не является ли оно приватным 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: Неправильная конфигурация безопасности#

Оставленные по умолчанию учётные данные, включённые ненужные HTTP-методы, подробные сообщения об ошибках в продакшне, включённый листинг директорий, неправильно настроенный CORS. Это категория «забыли запереть дверь».

typescript
// Не раскрывайте стек-трейсы в продакшне
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: Неправильное управление инвентаризацией#

Вы задеплоили v2 API, но забыли выключить v1. Или есть эндпоинт /debug/, который был полезен при разработке и всё ещё работает в продакшне. Или стейджинг-сервер, публично доступный с продакшн-данными.

Это не исправляется кодом — это дисциплина операций. Ведите список всех эндпоинтов API, всех развёрнутых версий и всех окружений. Используйте автоматическое сканирование для поиска открытых сервисов. Убивайте то, что не нужно.

API10: Небезопасное потребление API#

Ваш API потребляет сторонние API. Вы валидируете их ответы? Что будет, если вебхук-пейлоад от Stripe на самом деле от злоумышленника?

typescript
import crypto from "crypto";
 
// Верификация подписей вебхуков Stripe
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)
  );
}

Всегда проверяйте подписи вебхуков. Всегда валидируйте структуру ответов сторонних 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 цифры)
  • Номера социального страхования или государственные ID
  • 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 advisories, и его оценки критичности не всегда точны. Добавляйте дополнительные инструменты:

Автоматическое сканирование зависимостей#

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-токены истекают за 15 мин или меньше. Refresh-токены ротируются при использовании.Критический
3Хранение токеновRefresh-токены в httpOnly secure cookies. Никаких токенов в localStorage.Критический
4Авторизация на каждом эндпоинтеКаждый эндпоинт доступа к данным проверяет права на уровне объекта. BOLA протестирован.Критический
5Валидация входных данныхВесь пользовательский ввод валидируется через Zod или аналог. Нет сырого req.body в запросах.Критический
6SQL/NoSQL-инъекцииВсе запросы к БД используют параметризованные запросы или методы ORM. Нет конкатенации строк.Критический
7Rate limitingЭндпоинты аутентификации: 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Обработка ошибокПродакшн-ошибки возвращают общие сообщения. Нет стек-трейсов, нет SQL-ошибок наружу.Высокий
11СекретыНет секретов в коде или истории git. .env в .gitignore. Валидация при запуске.Критический
12Зависимостиnpm audit чист (нет high/critical). Lockfile закоммичен. npm ci в CI.Высокий
13Только HTTPSHSTS включён с preload. HTTP перенаправляет на HTTPS. Флаг secure на cookies.Критический
14ЛогированиеСобытия аутентификации, отказы в доступе и мутации данных логируются. Нет PII в логах.Средний
15Ограничения размера запросовBody parser ограничен (1 МБ по умолчанию). Загрузки файлов ограничены. Пагинация запросов работает.Средний
16Защита от SSRFПользовательские URL валидируются. Приватные IP блокируются. Редиректы отключены или проверяются.Средний
17Блокировка аккаунтаНеудачные попытки входа вызывают блокировку после 5 попыток. Блокировки логируются.Высокий
18Верификация вебхуковВсе входящие вебхуки проверяются по подписям. Защита от replay через временную метку.Высокий
19Админские эндпоинтыРолевой контроль доступа на всех админских маршрутах. Попытки логируются.Критический
20Mass assignmentЭндпоинты обновления используют Zod-схему с allowlist полей. Нет сырого разворачивания body.Высокий

Я храню это как шаблон GitHub issue. Перед тегированием релиза кто-то из команды должен проверить каждую строку и расписаться. Это не гламурно, но работает.

Изменение мышления#

Безопасность — это не фича, которую добавляют в конце. Это не спринт, который делают раз в год. Это образ мышления о каждой строке кода, которую вы пишете.

Когда вы пишете эндпоинт, думайте: «Что если кто-то отправит данные, которых я не ожидаю?» Когда добавляете параметр, думайте: «Что если кто-то изменит его на ID другого пользователя?» Когда добавляете зависимость, думайте: «Что будет, если этот пакет скомпрометируют в следующий вторник?»

Вы не поймаете всё. Никто не ловит. Но прохождение этого чек-листа — методично, перед каждым деплоем — ловит то, что имеет наибольшее значение. Быстрые победы. Очевидные дыры. Ошибки, которые превращают плохой день в утечку данных.

Выработайте привычку. Проходите чек-лист. Деплойте с уверенностью.

Похожие записи