Защита API: rate limiting, валидация, CORS и ключи

API ломает старую иллюзию фронтенда: «форма одна, пользователь через неё идёт». У API тысячи вызывающих, часть из них скрипты, часть — боты, а интерфейс запроса можно подделать. Защита строится на четырёх слоях: не дать захлебнуться (rate limiting), не принять мусор (валидация), не пустить чужой браузер (CORS), опознать и ограничить вызывающего (ключи и токены).

Статья практическая: PHP, немного nginx и WordPress-специфика REST API.

Часть I. Rate limiting

1. Считать по кому

Первая ошибка — лимит «на IP». За NATом корпорации стоит тысяча сотрудников с одним адресом, а атакующий за минуту ротирует IP. Правильно считать по нескольким ключам одновременно:

  • по API-ключу / user id — основной лимит (у каждого клиента своя квота, честная и переносимая);
  • по IP — страховка от анонимного флуда и перебора до аутентификации;
  • по эндпоинту — тяжёлые ручки (/export, поиск, LLM-запросы) лимитируются строже, чем /health.

Комбинация даёт «у пользователя — 600/мин, а с одного IP до логина — 20/мин».

2. Алгоритмы: фиксированное окно, скользящее, token bucket

АлгоритмСутьМинус
Fixed windowсчётчик на период, сброс по границе«дабл-спайк»: на стыке окон проходит 2× лимит
Sliding windowокно «скользит», учитывает прошлые меткиточнее, но больше состояния
Token bucketведро наполняется с постоянной скоростью, запрос берёт токенпропускает короткий всплеск до ёмкости ведра

Fixed window достаточно для грубой защиты; token bucket — стандарт, когда надо разрешить burst, но усреднить нагрузку (как у платных API).

3. Реализация на Redis (shared state)

Память процесса не годится: за балансировщиком несколько воркеров, и каждый считает «своих». Redis даёт общий атомарный счётчик.

PHP
<?php
// Token bucket: one-way, атомарно через Lua-скрипт Redis.
function rate_limit_check(Redis $redis, string $key, int $capacity, int $refillPerSec): array
{
    // Возвращает [allowed(bool), remaining(int), retryAfterSec(int)]
    $lua = <<<'LUA'
      local k = KEYS[1]
      local cap = tonumber(ARGV[1])
      local refill = tonumber(ARGV[2])
      local now = tonumber(ARGV[3])
      local s = redis.call('HMGET', k, 'tokens', 'ts')
      local tokens = tonumber(s[1]) or cap
      local ts = tonumber(s[2]) or now
      local delta = math.max(0, now - ts)
      tokens = math.min(cap, tokens + delta * refill)
      local allowed, retry = 0, 0
      if tokens >= 1 then
        tokens = tokens - 1
        allowed = 1
      else
        retry = math.ceil((1 - tokens) / refill)
      end
      redis.call('HSET', k, 'tokens', tokens, 'ts', now)
      redis.call('EXPIRE', k, math.ceil(cap / refill) + 1)
      return {allowed, math.floor(tokens), retry}
    LUA;
    [$a, $rem, $retry] = $redis->eval($lua, [$key, $capacity, $refillPerSec, time()], 1);
    return [(bool) $a, (int) $rem, (int) $retry];
}

Ключ rate:{user_id}:{endpoint}, значение — состояние ведра. EXPIRE чистит неактивные ключи, иначе коллектор разрастается.

4. Что отдать клиенту при лимите

PHP
<?php
if (!$allowed) {
    http_response_code(429);                 // Too Many Requests (RFC 6585)
    header('Retry-After: ' . $retry);         // секунды (или HTTP-date), RFC 9110
    header('X-RateLimit-Limit: ' . $capacity);
    header('X-RateLimit-Remaining: ' . $remaining);
    header('X-RateLimit-Reset: ' . $resetEpoch);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'rate_limited', 'retry_after' => $retry]);
    exit;
}
  • 429, а не 403/500: корректный сигнал вежливому клиенту подождать.
  • Retry-After обязателен по духу стандарта — без него клиенты повторяют сразу и усиливают шторм.
  • Заголовки X-RateLimit-* не стандартизированы жёстко, но это конвенция рынка; документируйте свои имена в спецификации API.

5. Где ставить и чего опасать

  • Ставьте лимит до дорогой работы (парсинг тела, запрос к БД, вызов LLM) — иначе флуд оплачивается вашими ресурсами.
  • Fail-open vs fail-closed. Если Redis упал: грубая защита падает открыто (пустите трафик, иначе лежит весь сервис), но для дорогих ручек — закрыто. Осознанный выбор, а не случайность.
  • Не делайте лимит единственным барьером против DDoS — это задача сети/CDN/WAF; rate limit защищает логику и квоты.

Часть II. Валидация ввода

6. Белый список полей и mass assignment

Каноническая дыра API — «приняли лишнее поле и сохранили». Клиент прислал {"title":"…","is_admin":true}, а модель заполняется присваиванием массива целиком:

PHP
<?php
// ⛔ Опасно: всё из JSON уходит в объект/БД
foreach ($input as $k => $v) { $record->$k = $v; }

// ✅ Белый список: берём только разрешённые поля, типы приведены
$title = (string) ($input['title'] ?? '');
$body  = (string) ($input['body']  ?? '');
// is_admin физически не читается из ввода — привилегии назначает сервер.

Правило: привилегии и связи (владелец, tenant_id, роль) не приходят из клиента, их берёт сервер из аутентификации. Клиент задаёт только содержательные поля, и то по allowlist.

7. Типы, пределы, диапазоны

Валидация — не только «пусто/не пусто», но и пределы, иначе мусор превращается в DoS:

PHP
<?php
function validate_comment(array $in): array
{
    $errors = [];
    $postId = filter_var($in['post_id'] ?? null, FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1, 'max_range' => PHP_INT_MAX]]);
    if ($postId === false) $errors[] = 'bad post_id';

    $text = (string) ($in['text'] ?? '');
    // mb_strlen — не strlen: лимит в символах, а не байтах, иначе кириллица "съест" норму
    if (mb_strlen($text, 'UTF-8') > 2000) $errors[] = 'text too long';

    return [$errors, ['post_id' => $postId, 'text' => $text]];
}

Что держать под пределом: длину строк, размер коллекций (массив id «на 1 000 000 элементов» убьёт запрос), глубину вложенности, размер тела запроса (лимит на web-сервере + проверка в коде), пагинацию (limit с максимумом, иначе ?limit=99999999).

8. ReDoS: регулярки как вектор

Валидатор-регекспа с вложенными квантификаторами катастрофически возвращается на «почти совпадении» и вешает процесс:

PHP
<?php
// ⛔ (a+)+, (\w+\s?)+, .*.* — экспоненциальное время на вредоносной строке
if (preg_match('/^(a+)+$/', $evil)) { /* ... */ }

// ✅ Простые линейные паттерны, жёсткий лимит длины ДО preg, и PCRE-лимит:
if (strlen($s) > 200) return false;
if (preg_match('/^[a-z0-9\-]{1,64}$/', $s)) { /* ... */ }

Меры: держите валидационные regex линейными (без вложенных +/*), проверяйте длину до запуска грепа, задайте pcre.backtrack_limit/pcre.recursion_limit в PHP. В nginx/маршрутизаторах тоже следите за жадными паттернами.

9. Строгий парсинг JSON и XML

  • JSON. Ограничьте глубину и размер. json_decode($raw, flags: JSON_THROW_ON_ERROR) + заранее if (strlen($raw) > MAX_BODY). PHP имеет max_depth у json_decode; не игнорируйте его. Проверяйте Content-Type: application/json и не парсите тело, если тип не тот (иначе принимают form-encoded мусор как JSON или наоборот — путаница типов = BAC).
  • XML. Классика — billion laughs / XML bomb и XXE. Если принимаете XML — отключите внешние сущности и лимитируйте раздувание:
PHP
<?php
// Ограничить размер тела ДО парсинга (против billion laughs / XML bomb):
if (strlen($raw) > 64 * 1024) { throw new InvalidArgumentException('xml too big'); }

// В PHP 8+ внешние сущности выключены по умолчанию; дополнительно запрещаем
// резолв внешних сущностей и сетевые обращения:
libxml_set_external_entity_loader(static fn () => null);
libxml_use_internal_errors(true);

$doc = new DOMDocument();
// НЕ ставим LIBXML_NOENT — этот флаг включает подстановку сущностей.
$ok = $doc->loadXML($raw, LIBXML_NONET | LIBXML_NOCDATA | LIBXML_BIGLINES);
if (!$ok) {
    libxml_clear_errors();
    throw new RuntimeException('bad xml');
}

Для REST чаще всего безопаснее не принимать XML вообще, если это не обязательный формат (SOAP, RSS-импорт).

10. Валидация — одинаковая на входе и на сервере

Клиентская валидация — про UX, не про безопасность. Сервер не имеет права полагаться на то, что «форма не даст». Проверяйте всё на бэкенде, в том числе то, что уже проверили на фронте; сверяйте идиомы (фронт режет по символам, бэк по байтам — расхождение открывает дыры).


Часть III. CORS: что это на самом деле

11. CORS ≠ аутентификация и ≠ «запретить запрос»

Ключевое непонимание: CORS не блокирует отправку запроса — браузер его отправит. CORS ограничивает чтение ответа скриптом с чужого origin, если сервер не разрешил. Отсюда следует, что CORS не защищает от CSRF (запрос-то ушёл — см. отдельную статью серии), но защищает данные от чтения вредоносной страницей.

Два режима:

  • Simple request (GET/POST с ограниченным набором заголовков и content-type) — браузер шлёт сразу, проверяет Access-Control-Allow-Origin в ответе.
  • Preflight — для кастомных заголовков (Authorization, X-*), методов PUT/DELETE/PATCH, Content-Type: application/json. Сначала без тела летит OPTIONS с Access-Control-Request-*, сервер должен ответить разрешением, только потом пойдёт реальный запрос.

12. Корректные заголовки

# Ответ на preflight (OPTIONS):
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

# Ответ на простой запрос:
Access-Control-Allow-Origin: https://app.example.com

Жёсткие правила браузеров, о которых спотыкаются:

  • Access-Control-Allow-Origin: * нельзя комбинировать с Access-Control-Allow-Credentials: true. Если нужны куки/Authorization, wildcard завернут — подставляйте точный origin.
  • Vary: Origin обязателен, когда origin подставляется динамически. Без него CDN/кеш отдаст ответ с разрешением для одного сайта другому — отравление кеша.
  • Access-Control-Max-Age кэширует preflight — меньше OPTIONS-трафика.

13. Allowlist origins: точное сравнение

⛔ Типичная дыра — префиксное совпадение:

PHP
<?php
// ❌ "начинается с" пропускает evil-example.com и example.com.attacker.net
if (str_starts_with($origin, 'https://example.com')) { allow(); }

// ✅ Точное сравнение с канонизированным (scheme+host+port, строчными) allowlist
function cors_allow(string $origin, string $serverName): bool
{
    $allowed = [
        'https://example.com',
        'https://www.example.com',
        'https://app.example.com',
    ];
    // Нормализуем: scheme://host[:port], host — в punycode/lowercase
    $host = strtolower((string) parse_url($origin, PHP_URL_HOST));
    $scheme = strtolower((string) parse_url($origin, PHP_URL_SCHEME));
    $port = parse_url($origin, PHP_URL_PORT);
    if ($host === '' || $scheme !== 'https') return false;   // только https
    $canonical = $scheme . '://' . $host . ($port ? ':' . $port : '');
    return in_array($canonical, $allowed, true);              // строгое равенство
}

Дополнительно проверяйте Host/SNI, чтобы запрос не шёл на ваш домен, но с чужим Origin. Для cookie-based API сверяйте Origin и на preflight, и на самом запросе — не только на OPTIONS.

14. CORS в nginx и в PHP

nginx (если заголовки ставит фронт-прокси, уберите дубли из PHP — двойные заголовки ломают CORS):

location /api/ {
    set $cors '';
    if ($http_origin ~* '^https://(app|www)\.example\.com$') {
        set $cors $http_origin;
    }
    add_header Access-Control-Allow-Origin  $cors always;
    add_header Vary                        Origin always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS' always;
    add_header Access-Control-Allow-Headers 'Authorization, Content-Type, X-Request-Id' always;

    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin  $cors;
        add_header Vary Origin;
        add_header Access-Control-Max-Age 600;
        add_header Content-Length 0;
        return 204;
    }
}

Регексп ^https://(app|www)\.example\.com$ — с якорями ^$ и экранированными точками, иначе снова префикс-обход.


Часть IV. Ключи API и токены

15. API-ключ: генерация, передача, хранение

  • Генерация. Криптостойкая энтропия: bin2hex(random_bytes(24)) (48 hex) или base64url. Читаемый префикс помогает (cs_live_ab12…) — по нему понимают тип и ищут в логах утечек.
  • Передача. Только в заголовке (Authorization: Bearer … или X-Api-Key). ⛔ Не в URL-query: query-параметры оседают в логах web-сервера, прокси, истории браузера и утекают в Referer на внешние страницы.
  • HTTPS обязателен. Ключ по http — plaintext, независимо от того, как он хранится на сервере.

Хранение на сервере — хеш, не plaintext. Здесь важный нюанс: в отличие от паролей, ключ — высокоэнтропийный случайный секрет, его не переберут словарём, поэтому быстрый SHA-256 корректен (bcrypt/argon здесь не нужен и даже мешает — нужен быстрый поиск по ключу).

PHP
<?php
// Создание: показать ключ ОДИН раз, в БД — только хеш и префикс.
$plain  = 'cs_live_' . rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '=');
$hash   = hash('sha256', $plain);
db_insert('api_keys', [
    'key_prefix' => substr($plain, 0, 12),   // для отображения/поиска списка
    'key_hash'   => $hash,                    // для проверки
    'user_id'    => $uid,
    'scopes'     => ['read:posts', 'write:comments'],
    'created_at' => time(),
    'last_used_at' => null,
]);
show_once($plain); // повторно не показать — восстановить нельзя, только перевыпустить

// Проверка входящего ключа:
function auth_by_key(string $presented): ?array
{
    $hash = hash('sha256', $presented);
    // Индекс по key_hash — O(1), тайминг не утекает (сравнивает БД по значению хеша).
    return db_select_one('SELECT user_id, scopes FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL', [$hash]);
}

16. Scope, срок, ротация, отзыв

Зрелый ключевой механизм отличается от «одной вечной строки» четырьмя вещами:

  • Scope. read:posts vs write:* — минимальные привилегии. Ключ, утёкший из мобильной сборки, не должен уметь всё.
  • Срок жизни и ротация. Возможность выпустить второй ключ, не ломая первый, потом отозвать первый (grace period). Это спасает при инциденте утечки.
  • Отзыв. Поле revoked_at; проверка требует revoked_at IS NULL. Мгновенный отзыв — то, что отличает хранимый ключ от JWT «до истечения срока» (ср. статью про аутентификацию).
  • Аудит. last_used_at, журнал «ключ X дёрнул эндпоинт Y с IP Z» — без этого утечку не расследовать.

17. HMAC-подпись запросов (против replay)

Когда ключ-секрет нельзя светить в каждом запросе (публичные вебхуки, подписанные вызовы), используют HMAC: клиент подписывает запрос секретом, сервер сверяет подпись, секрет не передаётся.

PHP
<?php
// Клиент: подпись = HMAC-SHA256(secret, METHOD \n path \n timestamp \n body)
function sign(string $secret, string $method, string $path, int $ts, string $body): string {
    $payload = implode("\n", [strtoupper($method), $path, (string)$ts, hash('sha256', $body)]);
    return hash_hmac('sha256', $payload, $secret);
}

// Сервер: сверка + защита от повтора (replay).
function verify(array $req, string $secret, Redis $redis): bool
{
    $ts = (int) ($req['x-ts'] ?? 0);
    if (abs(time() - $ts) > 300) return false;            // окно 5 минут
    $nonce = (string) ($req['x-nonce'] ?? '');
    if ($nonce === '' || !$redis->set("nonce:$nonce", 1, ['NX', 'EX' => 600])) {
        return false;                                       // уже видели — повтор
    }
    $expected = sign($secret, $req['method'], $req['path'], $ts, $req['body']);
    return hash_equals($expected, (string) ($req['x-sign'] ?? ''));
}

Обязательные элементы подписи, иначе её переиспользуют: timestamp + окно (устаревание), nonce + хранилище (одноразовость), body/путь/метод внутри подписи (иначе подпись «пристегнут» к любому запросу). Сравнение — только hash_equals.

18. Bearer-токены (OAuth) против ключей

  • API-ключ (X-Api-Key) — долгоживущий, статичный, «как пароль»: простой, но при утечке живёт, пока не отзовёшь.
  • Bearer-токен (Authorization: Bearer …) — короткоживущий, выдаётся по протоколу (OAuth2), обновляется refresh-токеном. Безопаснее для пользовательского доступа, сложнее в реализации.
  • Взаимно заменяют друг друга лишь частично: для server-to-server у сервиса удобен ключ или client credentials; для «пользователь дал приложению доступ» — OAuth2 (детали потоков и PKCE — в статье про аутентификацию).

19. Логирование, секреты, «ключи в git»

  • Не пишите ключи/токены в логи. В access.log ключ утёк через query — закройте ручку от логирования sensitive-параметров или режьте значения фильтром.
  • Секреты подписи/webhook — в env, не в репозитории; ключ, однажды попавший в git-историю, считается скомпрометированным (чистка истории не «закрывает» факт утечки).
  • Утечку в публичный репозиторий ловят сканеры секретов (trufflehog/gitleaks и встроенные сканеры хостингов) — но реагировать надо отзывом, а не удалением коммита.

Часть V. Специфика WordPress REST API

20. Аутентификация в WP REST

По умолчанию /wp-json/wp/v2/* для чтения открыт, запись требует аутентификации. Доступные корректные способы:

  • Cookies + X-WP-Nonce — для того же браузера (nonce обязателен, иначе CSRF; см. статью про XSS/CSRF).
  • Application Passwords (Authorization: Basic, базовый логин + 24-символьный app-пароль) — штатный способ для внешних клиентов; отображается один раз, хранится хешем, отзывается в профиле. Требует HTTPS.
  • JWT-плагины — с теми же оговорками, что и везде: фиксированный alg, короткий TTL, секрет вне репо (см. статью про аутентификацию).

21. Скрыть и ограничить

  • Скрыть карту API от анонимов. Корень /wp-json/ перечисляет все роуты — это разведданные. rest_authentication_errors для user_id=0 и/или rest_endpoints фильтр:
PHP
<?php
add_filter('rest_authentication_errors', function ($result) {
    if (!is_user_logged_in()) {
        // Закрыть анонимный доступ к перечислению/чтению, если он не нужен.
        return new WP_Error('rest_forbidden',
            'Authentication required', ['status' => is_user_logged_in() ? 403 : 401]);
    }
    return $result;
});

// Убрать публичные типы записей из анонимного чтения, если не надо:
// add_filter('rest_endpoints', fn($eps) => array_filter(
//     $eps, fn($k) => !str_starts_with($k, '/wp/v2/posts'), ARRAY_FILTER_USE_KEY));
  • Rate limit к REST — плагином или на уровне nginx (см. раздел 5). Голый WP лимитов не имеет; wp-login и REST без лимита — открытая дверь к перебору.
  • Отключите неиспользуемое: xmlrpc.php (источник amplified brute force и pingback-DRDoS), если не нужен; ненужные namespaces из REST; ?rest_route= обходPretty-permalink-фильтров учитывайте при настройке правил.

22. Валидация и CORS в WP

  • У WP REST есть декларативные args с sanitize_callback/validate_callback и type/minimum/maxLength на уровне регистрации роута — пользуйтесь ими, а не «разберём WP_REST_Request руками»:
PHP
<?php
register_rest_route('cs/v1', '/comment', [
    'methods'  => 'POST',
    'callback' => 'cs_save_comment',
    'permission_callback' => fn($r) => is_user_logged_in(), // ОБЯЗАТЕЛЕН с 5.5
    'args' => [
        'post_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
        'text'    => ['type' => 'string',  'required' => true, 'maxLength' => 2000],
    ],
]);
  • permission_callback обязателен — без него WP может разрешить вызов анониму; он про права, а не про аутентификацию.
  • CORS в WP: заголовки ставьте на уровне nginx (надёжнее и без дублей), а rest_cookie_extra_fields/Vary учитывайте при cookie-auth.

Инструмент под задачу

Свести правила CORS / rate-limit / заголовки безопасности в рабочий .htaccess (или nginx-блок) по живому анализу, а не по памяти. Попробуйте инструмент прямо здесь, на своих данных:

AI Htaccess Analyzer
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь — для пользователей лимит + 200 000 токенов.
0 / 20 000 символов

Результат анализа
Вставьте .htaccess слева и нажмите «Анализировать».
ИИ найдёт уязвимости и предложит исправления.

Чек-лист

Rate limiting:

  • Счёт по ключу/пользователю + по IP + по «дорогим» эндпоинтам; общий state в Redis, не в памяти воркера.
  • До дорогой работы; 429 + Retry-After + X-RateLimit-*; осознанный fail-open/fail-closed при отказе хранилища.

Валидация:

  • Белый список полей; привилегии/tenant/владелец — только с сервера (нет mass assignment).
  • Типы + пределы: длина (mb-символы), размер коллекций, глубина, размер тела, limit пагинации с максимумом.
  • Валидационные regex линейные, длина до preg, pcre.*_limit; Content-Type проверен; XML — без внешних сущностей или не принимается.
  • Сервер проверяет всё, независимо от клиентской валидации.

CORS:

  • Понимание: CORS про чтение ответа, не про запрет запроса; это не CSRF-защита.
  • Точный origin (не wildcard с credentials); Vary: Origin при динамическом origin; allowlist через строгое равенство с канонизацией; сверка Origin и на preflight, и на запросе.
  • Нет двойных Access-Control-* заголовков (nginx XOR PHP).

Ключи:

  • Высокая энтропия, читаемый префикс; передача только в заголовке и только по HTTPS; никогда в query.
  • На сервере — SHA-256 от ключа (ключи энтропийные), plaintext не хранится, показ один раз.
  • Scope, срок, ротация с grace period, мгновенный отзыв (revoked_at), аудит last_used.
  • Для подписанных запросов — HMAC с timestamp+окно, одноразовый nonce, тело/путь/метод в подписи, hash_equals.
  • Секреты в env; сканер утечек; компрометация = отзыв, а не git rm.

WordPress:

  • App Passwords / cookies+nonce / JWT с фиксированным alg; закрыт анонимный перечисление роутов при необходимости.
  • Rate limit REST и wp-login; xmlrpc закрыт; permission_callback обязателен; декларативные args-валидаторы; CORS на nginx без дублей.

Источники

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.