MidПрактика18 min

Безопасность API

Rate limiting, input validation, CORS, API keys vs tokens и PHP middleware безопасности

Основные угрозы для API

Угроза Описание Защита
Brute force Перебор паролей/ключей Rate limiting
Injection SQL/NoSQL/Command injection Input validation
BOLA Broken Object Level Auth Authorization checks
Data exposure Лишние данные в ответе Response filtering
Mass assignment Подмена полей в запросе Whitelist полей
DDoS Перегрузка API Rate limiting, WAF

Rate Limiting

Алгоритмы Rate Limiting

Алгоритм Описание Плюсы Минусы
Fixed Window Счётчик за фиксированный интервал Простой Burst на границе окон
Sliding Window Скользящее окно Точный Больше памяти
Token Bucket Токены накапливаются с фиксированной скоростью Допускает burst Сложнее
Leaky Bucket Запросы обрабатываются с фиксированной скоростью Smooth output Задержки

Rate Limiter

<?php

declare(strict_types=1);

namespace App\Security;

final class SlidingWindowRateLimiter
{
    // The whole check runs as one Lua script so that trimming, counting and
    // inserting are atomic — a pipeline would race between concurrent callers:
    // both could read the same count and both be allowed past the limit.
    private const string SCRIPT = <<<'LUA'
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window = tonumber(ARGV[2])
        local max_requests = tonumber(ARGV[3])
        local member = ARGV[4]

        redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)

        local count = redis.call('ZCARD', key)
        if count >= max_requests then
            return -1
        end

        redis.call('ZADD', key, now, member)
        redis.call('EXPIRE', key, window)

        return max_requests - count - 1
        LUA;

    public function __construct(
        private readonly \Redis $redis,
    ) {}

    /**
     * Check if request is allowed under rate limit.
     *
     * @param string $key Identifier (user_id, IP, API key)
     * @param int $maxRequests Maximum requests in the window
     * @param int $windowSeconds Window size in seconds
     */
    public function attempt(string $key, int $maxRequests, int $windowSeconds): RateLimitResult
    {
        $now = microtime(true);
        $member = sprintf('%.6F:%s', $now, bin2hex(random_bytes(4)));

        // Nothing is written when the limit is already reached, so there is no
        // "undo" step that could delete another caller's entry.
        $remaining = (int) $this->redis->eval(
            self::SCRIPT,
            [
                "rate_limit:{$key}",
                (string) $now,
                (string) $windowSeconds,
                (string) $maxRequests,
                $member,
            ],
            1,
        );

        if ($remaining < 0) {
            return new RateLimitResult(
                allowed: false,
                remaining: 0,
                retryAfterSeconds: $windowSeconds,
                limit: $maxRequests,
            );
        }

        return new RateLimitResult(
            allowed: true,
            remaining: $remaining,
            retryAfterSeconds: 0,
            limit: $maxRequests,
        );
    }
}

final readonly class RateLimitResult
{
    public function __construct(
        public bool $allowed,
        public int $remaining,
        public int $retryAfterSeconds,
        public int $limit,
    ) {}
}
### Rate Limiting Middleware
<?php

declare(strict_types=1);

namespace App\Middleware;

use App\Security\SlidingWindowRateLimiter;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;

final readonly class RateLimitMiddleware
{
    public function __construct(
        private SlidingWindowRateLimiter $limiter,
    ) {}

    public function onKernelRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();

        // Keyed by the connection IP, never by a client-controlled header:
        // X-Forwarded-For is spoofable unless trusted proxies are configured.
        $key = $request->server->get('REMOTE_ADDR') ?? 'unknown';

        // 100 requests per minute per IP
        $result = $this->limiter->attempt($key, maxRequests: 100, windowSeconds: 60);

        if (!$result->allowed) {
            $response = new JsonResponse(
                ['error' => 'Too many requests'],
                429,
            );
            $response->headers->set('Retry-After', (string) $result->retryAfterSeconds);
            $response->headers->set('X-RateLimit-Limit', (string) $result->limit);
            $response->headers->set('X-RateLimit-Remaining', '0');

            $event->setResponse($response);
            return;
        }

        // Add rate limit headers to response via listener
        $request->attributes->set('rate_limit_remaining', $result->remaining);
        $request->attributes->set('rate_limit_limit', $result->limit);
    }
}
## Input Validation

Validation Middleware

<?php

declare(strict_types=1);

namespace App\Validation;

final readonly class InputValidator
{
    private const int MAX_QUANTITY = 10000;
    private const int MAX_NAME_LENGTH = 255;

    /** Allow-list of accepted fields — anything else is a mass-assignment attempt. */
    private const array ALLOWED_FIELDS = ['product_name', 'quantity', 'price', 'email'];

    /**
     * Validate and sanitize order creation input.
     *
     * @param array<string, mixed> $data Raw input
     * @return array{valid: bool, errors: array<string>, sanitized: array<string, mixed>}
     */
    public function validateOrderInput(array $data): array
    {
        $errors = [];
        $sanitized = [];

        // Unknown fields are rejected, not silently dropped: a caller sending
        // "is_admin" or "total_price" must get an error, not a partial success.
        foreach (array_diff(array_keys($data), self::ALLOWED_FIELDS) as $unknown) {
            $errors[] = sprintf('%s is not an accepted field', $unknown);
        }

        // Required string field with max length
        if (!isset($data['product_name']) || !is_string($data['product_name'])) {
            $errors[] = 'product_name is required and must be a string';
        } else {
            $name = trim($data['product_name']);

            // mb_strlen counts characters, strlen counts bytes: a 255-byte
            // limit would silently truncate non-ASCII names.
            if (mb_strlen($name) < 1 || mb_strlen($name) > self::MAX_NAME_LENGTH) {
                $errors[] = sprintf('product_name must be between 1 and %d characters', self::MAX_NAME_LENGTH);
            } else {
                $sanitized['product_name'] = $name;
            }
        }

        // Positive integer within range — out-of-range input is rejected rather
        // than clamped, so the client never gets a quantity it did not ask for.
        if (!isset($data['quantity']) || !is_int($data['quantity'])
            || $data['quantity'] < 1 || $data['quantity'] > self::MAX_QUANTITY
        ) {
            $errors[] = sprintf('quantity must be an integer between 1 and %d', self::MAX_QUANTITY);
        } else {
            $sanitized['quantity'] = $data['quantity'];
        }

        // Positive float with precision
        if (!isset($data['price']) || !is_numeric($data['price']) || (float) $data['price'] <= 0) {
            $errors[] = 'price must be a positive number';
        } else {
            $sanitized['price'] = round((float) $data['price'], 2, PHP_ROUND_HALF_EVEN);
        }

        // Email validation
        if (isset($data['email'])) {
            $email = filter_var($data['email'], FILTER_VALIDATE_EMAIL);
            if ($email === false) {
                $errors[] = 'email is not valid';
            } else {
                $sanitized['email'] = $email;
            }
        }

        return [
            'valid' => $errors === [],
            'errors' => $errors,
            // Only returned when everything validated: a half-sanitized payload
            // must never reach the domain layer.
            'sanitized' => $errors === [] ? $sanitized : [],
        ];
    }
}
## CORS (Cross-Origin Resource Sharing)

CORS контролирует, какие домены могут делать запросы к API.

<?php

declare(strict_types=1);

namespace App\Middleware;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;

final readonly class CorsMiddleware
{
    /** @var array<string> */
    private const ALLOWED_ORIGINS = [
        'https://app.example.com',
        'https://admin.example.com',
    ];

    private const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
    private const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Request-ID';
    private const MAX_AGE = 3600; // Preflight cache: 1 hour

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // Handle preflight OPTIONS request
        if ($request->getMethod() === 'OPTIONS') {
            $response = new Response('', 204);
            $this->addCorsHeaders($response, $request);
            $event->setResponse($response);
        }
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        $this->addCorsHeaders($event->getResponse(), $event->getRequest());
    }

    private function addCorsHeaders(Response $response, Request $request): void
    {
        $origin = $request->headers->get('Origin', '');

        // Always advertise that the response body depends on Origin, otherwise
        // a shared cache can serve one origin's response to another.
        $response->headers->set('Vary', 'Origin');

        // Only allow whitelisted origins
        if (!in_array($origin, self::ALLOWED_ORIGINS, true)) {
            return;
        }

        $response->headers->set('Access-Control-Allow-Origin', $origin);
        $response->headers->set('Access-Control-Allow-Methods', self::ALLOWED_METHODS);
        $response->headers->set('Access-Control-Allow-Headers', self::ALLOWED_HEADERS);
        $response->headers->set('Access-Control-Max-Age', (string) self::MAX_AGE);
        $response->headers->set('Access-Control-Allow-Credentials', 'true');
    }
}
## API Keys vs Tokens
API Key JWT Token
Кто использует Сервисы, приложения Пользователи
Срок жизни Долгий (месяцы) Короткий (минуты)
Информация Только идентификатор Claims (user, roles)
Revocation Удалить из БД Blacklist / wait expiry
Подходит для Server-to-server User-facing API

Security Headers

<?php

declare(strict_types=1);

namespace App\Middleware;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;

final class SecurityHeadersMiddleware
{
    public function onKernelResponse(ResponseEvent $event): void
    {
        $response = $event->getResponse();

        // Prevent MIME type sniffing
        $response->headers->set('X-Content-Type-Options', 'nosniff');

        // Prevent clickjacking
        $response->headers->set('X-Frame-Options', 'DENY');

        // XSS protection (legacy browsers)
        $response->headers->set('X-XSS-Protection', '1; mode=block');

        // Strict Transport Security
        $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');

        // Content Security Policy
        $response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'");

        // Referrer Policy
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');

        // Permissions Policy
        $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
    }
}
## Итоги
Концепция Суть
Rate Limiting Sliding window + Redis для защиты от abuse
Input Validation Validate + sanitize каждый вход
CORS Whitelist разрешённых origin-ов
Security Headers HSTS, CSP, X-Frame-Options на каждый ответ
API Keys Для сервис-to-сервис, длинные
JWT Tokens Для пользователей, короткие с refresh