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
// 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
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
// ⛔ Опасно: всё из 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
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
// ⛔ (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
// Ограничить размер тела ДО парсинга (против 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
// ❌ "начинается с" пропускает 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
// Создание: показать ключ ОДИН раз, в БД — только хеш и префикс.
$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:postsvswrite:*— минимальные привилегии. Ключ, утёкший из мобильной сборки, не должен уметь всё. - Срок жизни и ротация. Возможность выпустить второй ключ, не ломая первый, потом отозвать первый (grace period). Это спасает при инциденте утечки.
- Отзыв. Поле
revoked_at; проверка требуетrevoked_at IS NULL. Мгновенный отзыв — то, что отличает хранимый ключ от JWT «до истечения срока» (ср. статью про аутентификацию). - Аудит.
last_used_at, журнал «ключ X дёрнул эндпоинт Y с IP Z» — без этого утечку не расследовать.
17. HMAC-подпись запросов (против replay)
Когда ключ-секрет нельзя светить в каждом запросе (публичные вебхуки, подписанные вызовы), используют HMAC: клиент подписывает запрос секретом, сервер сверяет подпись, секрет не передаётся.
<?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
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
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-блок) по живому анализу, а не по памяти. Попробуйте инструмент прямо здесь, на своих данных:
ИИ найдёт уязвимости и предложит исправления.
Чек-лист
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 без дублей.
Источники
- OWASP — REST Security Cheat Sheet, API Security Cheat Sheet (v2.0)
- MDN — Cross-Origin Resource Sharing (CORS), HTTP 429, Retry-After
- RFC 6585 (429 Too Many Requests), RFC 9110 (HTTP Semantics,
Retry-After,Vary), RFC 6749 (The OAuth 2.0 Authorization Framework), RFC 8707 (Resource Indicators) - PortSwigger Web Security Academy — CORS, API testing
- PHP — random_bytes, hash_hmac, json_decode, filter_var,
pcre.backtrack_limit/pcre.recursion_limit - WordPress — REST API Handbook — Security, Application Passwords, register_rest_route
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
