Learnly
Проектирование API/Глава 17/Урок 1

Безопасность API: rate limiting, CORS, валидация

Безопасность API: rate limiting, CORS, валидация

Безопасность API, это не параноя, это инженерная необходимость. Публичный API без защиты, это открытая дверь для злоупотреблений, утечек данных и DDoS атак.


Rate Limiting

Rate limiting ограничивает количество запросов которые клиент может сделать за период времени. Без него один агрессивный клиент может положить весь сервис.

Алгоритмы rate limiting

Token Bucket (ведро с токенами)

Представь ведро которое наполняется токенами с постоянной скоростью. Каждый запрос тратит один токен. Если токенов нет, запрос отклоняется.

  • Ведро вмещает 100 токенов (burst capacity)
  • Пополняется со скоростью 10 токенов/секунду
  • Клиент может сделать 100 запросов сразу, потом по 10/сек

Позволяет краткосрочные всплески при плавном долгосрочном ограничении.

Fixed Window (фиксированное окно)

100 запросов в минуту. Счётчик сбрасывается каждую минуту в 00 секунд.

Проблема: 100 запросов в 00:59 + 100 запросов в 01:01 = 200 запросов за 2 секунды.

Sliding Window (скользящее окно)

Считает запросы за последние N секунд относительно текущего момента. Нет проблемы граничных значений. Дороже вычислительно.

Ответ при превышении лимита

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710498060
Retry-After: 60

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Превышен лимит запросов. Повторите через 60 секунд."
  }
}

Разные лимиты для разных клиентов

Анонимные пользователи:  100 запросов/час
Бесплатный план:         1 000 запросов/день
Платный план:            100 000 запросов/день
Enterprise:              без ограничений (или кастомный лимит)

Реализация на уровне API Gateway (nginx, Kong) или в коде:

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 минут
  max: 100,
  standardHeaders: true,     // X-RateLimit-* заголовки
  legacyHeaders: false,
  keyGenerator: (req) => req.user?.id || req.ip,  // по пользователю или IP
  handler: (req, res) => {
    res.status(429).json({
      error: {
        code: 'RATE_LIMIT_EXCEEDED',
        message: 'Слишком много запросов'
      }
    });
  }
});

app.use('/api/', limiter);

CORS (Cross-Origin Resource Sharing)

CORS, механизм браузера который контролирует обращения к API с других доменов.

Зачем CORS существует

Браузер блокирует JavaScript-запросы к другому домену без явного разрешения. Это защита пользователя: вредоносный сайт не может тихо делать запросы к твоему банку от имени залогиненного пользователя.

Твой сайт: https://app.learnly.online
API:       https://api.learnly.online

Без CORS: браузер заблокирует запрос из app к api
С CORS:   api говорит браузеру "доверяю app.learnly.online"

Простые и предваряющие запросы

Простые запросы (GET, POST с application/json), браузер делает запрос, проверяет заголовки ответа.

Предваряющие запросы (preflight), перед «опасными» запросами (PUT, DELETE, нестандартные заголовки) браузер сначала отправляет OPTIONS:

OPTIONS /api/v1/users/42
Origin: https://app.learnly.online
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.learnly.online
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Настройка CORS в Express

import cors from 'cors';

const allowedOrigins = [
  'https://app.learnly.online',
  'https://www.learnly.online',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null
].filter(Boolean);

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} не разрешён`));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
  credentials: true,      // разрешить куки в кросс-доменных запросах
  maxAge: 86400           // кэшировать preflight на 24 часа
}));

Никогда не используй Access-Control-Allow-Origin: * с credentials. Это противоречит стандарту и создаёт уязвимости.


Валидация входных данных

Валидация, первая линия обороны. Любые данные от клиента, потенциально вредоносные.

Что валидировать

Типы данных:

// Запрос:
{ "age": "двадцать пять" }

// Должен вернуть 422, а не упасть с 500

Обязательные поля:

// Без email нельзя создать пользователя
{ "name": "Иван" }

Диапазоны значений:

// Количество товара не может быть отрицательным
{ "quantity": -5 }

Форматы:

// Невалидный email
{ "email": "это-не-email" }

Валидация с помощью Zod (TypeScript)

import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string()
    .min(2, 'Имя слишком короткое')
    .max(100, 'Имя слишком длинное'),
  email: z.string()
    .email('Неверный формат email'),
  password: z.string()
    .min(8, 'Пароль минимум 8 символов')
    .regex(/[A-Z]/, 'Нужна хотя бы одна заглавная буква'),
  age: z.number()
    .int('Возраст должен быть целым числом')
    .min(18, 'Минимальный возраст 18 лет')
    .max(120)
    .optional()
});

app.post('/api/v1/users', async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
  
  if (!result.success) {
    return res.status(422).json({
      error: {
        code: 'VALIDATION_FAILED',
        message: 'Ошибки валидации',
        details: result.error.errors.map(err => ({
          field: err.path.join('.'),
          code: err.code.toUpperCase(),
          message: err.message
        }))
      }
    });
  }
  
  // result.data, безопасные данные
  const user = await createUser(result.data);
  res.status(201).json(user);
});

Защита от SQL Injection

Никогда не подставляй данные пользователя напрямую в SQL:

// НИКОГДА ТАК, SQL Injection
const users = await db.query(
  `SELECT * FROM users WHERE email = '${req.body.email}'`
);

// ПРАВИЛЬНО, параметризованные запросы
const users = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [req.body.email]
);

Ограничение размера тела запроса

app.use(express.json({ limit: '10mb' }));

Без ограничения атакующий может отправить гигабайтное тело запроса и исчерпать память сервера.


HTTPS, обязателен

Все API в продакшне должны работать только через HTTPS. HTTP передаёт токены и данные в открытом виде.

// Редирект с HTTP на HTTPS
app.use((req, res, next) => {
  if (req.header('x-forwarded-proto') !== 'https' && 
      process.env.NODE_ENV === 'production') {
    return res.redirect(`https://${req.header('host')}${req.url}`);
  }
  next();
});

Чеклист безопасности API

  • Rate limiting настроен
  • CORS ограничен конкретными доменами
  • Все входные данные валидируются
  • Параметризованные SQL запросы везде
  • Ограничение размера тела запроса
  • HTTPS в продакшне
  • Токены не попадают в логи
  • Stack trace не возвращается клиенту
  • Заголовки безопасности (helmet в Express)
AI-тест
1 / 5

Какие запросы CORS передает предварительный запрос (preflight)?