Mid💻Практика8 min

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

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

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

Основные угрозы для 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 Задержки

PHP: Rate Limiter

PHPGo
<?php

declare(strict_types=1);

namespace App\Security;

final class SlidingWindowRateLimiter
{
    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
     * @return RateLimitResult
     */
    public function attempt(string $key, int $maxRequests, int $windowSeconds): RateLimitResult
    {
        $now = microtime(true);
        $windowStart = $now - $windowSeconds;
        $redisKey = "rate_limit:{$key}";

        // Use sorted set with timestamps as scores
        $pipe = $this->redis->multi(\Redis::PIPELINE);

        // Remove expired entries
        $pipe->zRemRangeByScore($redisKey, '-inf', (string) $windowStart);

        // Count current entries
        $pipe->zCard($redisKey);

        // Add current request
        $pipe->zAdd($redisKey, $now, (string) $now . ':' . bin2hex(random_bytes(4)));

        // Set TTL on the key
        $pipe->expire($redisKey, $windowSeconds);

        $results = $pipe->exec();
        $currentCount = (int) $results[1];

        if ($currentCount >= $maxRequests) {
            // Remove the entry we just added
            $this->redis->zRemRangeByRank($redisKey, -1, -1);

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

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

final readonly class RateLimitResult
{
    public function __construct(
        public bool $allowed,
        public int $remaining,
        public int $retryAfterSeconds,
        public int $limit,
    ) {}
}
package security

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

// RateLimitResult holds the outcome of a rate limit check.
type RateLimitResult struct {
	Allowed           bool `json:"allowed"`
	Remaining         int  `json:"remaining"`
	RetryAfterSeconds int  `json:"retry_after_seconds"`
	Limit             int  `json:"limit"`
}

// SlidingWindowRateLimiter uses Redis sorted sets for sliding window rate limiting.
type SlidingWindowRateLimiter struct {
	rdb *redis.Client
}

// NewSlidingWindowRateLimiter creates a new rate limiter.
func NewSlidingWindowRateLimiter(rdb *redis.Client) *SlidingWindowRateLimiter {
	return &SlidingWindowRateLimiter{rdb: rdb}
}

// Attempt checks if a request is allowed under the rate limit.
func (l *SlidingWindowRateLimiter) Attempt(ctx context.Context, key string, maxReqs, windowSec int) RateLimitResult {
	now := float64(time.Now().UnixMicro()) / 1e6
	windowStart := now - float64(windowSec)
	redisKey := "rate_limit:" + key

	pipe := l.rdb.Pipeline()
	pipe.ZRemRangeByScore(ctx, redisKey, "-inf", fmt.Sprintf("%f", windowStart))
	countCmd := pipe.ZCard(ctx, redisKey)

	b := make([]byte, 4)
	rand.Read(b)
	member := fmt.Sprintf("%f:%s", now, hex.EncodeToString(b))
	pipe.ZAdd(ctx, redisKey, redis.Z{Score: now, Member: member})
	pipe.Expire(ctx, redisKey, time.Duration(windowSec)*time.Second)
	pipe.Exec(ctx)

	count := int(countCmd.Val())
	if count >= maxReqs {
		l.rdb.ZRemRangeByRank(ctx, redisKey, -1, -1)
		return RateLimitResult{Allowed: false, Remaining: 0, RetryAfterSeconds: windowSec, Limit: maxReqs}
	}

	return RateLimitResult{Allowed: true, Remaining: maxReqs - count - 1, Limit: maxReqs}
}
### Rate Limiting Middleware
PHPGo
<?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();
        $key = $request->getClientIp() ?? '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);
    }
}
package middleware

import (
	"encoding/json"
	"fmt"
	"net/http"

	"myapp/security"
)

// RateLimitMiddleware enforces rate limiting on incoming requests.
func RateLimitMiddleware(limiter *security.SlidingWindowRateLimiter) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			key := r.RemoteAddr
			result := limiter.Attempt(r.Context(), key, 100, 60) // 100 req/min

			if !result.Allowed {
				w.Header().Set("Retry-After", fmt.Sprintf("%d", result.RetryAfterSeconds))
				w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", result.Limit))
				w.Header().Set("X-RateLimit-Remaining", "0")
				w.WriteHeader(http.StatusTooManyRequests)
				json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests"})
				return
			}

			w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", result.Remaining))
			w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", result.Limit))
			next.ServeHTTP(w, r)
		})
	}
}
## Input Validation

PHP: Validation Middleware

PHPGo
<?php

declare(strict_types=1);

namespace App\Validation;

final readonly class InputValidator
{
    /**
     * 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 = [];

        // 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']);
            if (strlen($name) < 1 || strlen($name) > 255) {
                $errors[] = 'product_name must be between 1 and 255 characters';
            }
            $sanitized['product_name'] = $name;
        }

        // Positive integer
        if (!isset($data['quantity']) || !is_int($data['quantity']) || $data['quantity'] < 1) {
            $errors[] = 'quantity must be a positive integer';
        } else {
            $sanitized['quantity'] = min($data['quantity'], 10000); // Cap at max
        }

        // 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);
        }

        // 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' => empty($errors),
            'errors' => $errors,
            'sanitized' => $sanitized,
        ];
    }
}
package validation

import (
	"fmt"
	"net/mail"
	"strings"
)

// ValidationResult holds the outcome of input validation.
type ValidationResult struct {
	Valid     bool              `json:"valid"`
	Errors   []string          `json:"errors"`
	Sanitized map[string]any   `json:"sanitized"`
}

// ValidateOrderInput validates and sanitizes order creation input.
func ValidateOrderInput(data map[string]any) ValidationResult {
	var errs []string
	sanitized := make(map[string]any)

	// Required string field with max length
	if name, ok := data["product_name"].(string); !ok {
		errs = append(errs, "product_name is required and must be a string")
	} else {
		name = strings.TrimSpace(name)
		if len(name) < 1 || len(name) > 255 {
			errs = append(errs, "product_name must be between 1 and 255 characters")
		}
		sanitized["product_name"] = name
	}

	// Positive integer
	if qty, ok := data["quantity"].(float64); !ok || qty < 1 {
		errs = append(errs, "quantity must be a positive integer")
	} else {
		if qty > 10000 {
			qty = 10000
		}
		sanitized["quantity"] = int(qty)
	}

	// Positive float
	if price, ok := data["price"].(float64); !ok || price <= 0 {
		errs = append(errs, "price must be a positive number")
	} else {
		sanitized["price"] = float64(int(price*100)) / 100
	}

	// Email validation
	if email, ok := data["email"].(string); ok {
		if _, err := mail.ParseAddress(email); err != nil {
			errs = append(errs, "email is not valid")
		} else {
			sanitized["email"] = email
		}
	}

	return ValidationResult{Valid: len(errs) == 0, Errors: errs, Sanitized: sanitized}
}
## CORS (Cross-Origin Resource Sharing)

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

PHPGo
<?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', '');

        // 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');
    }
}
package middleware

import "net/http"

var allowedOrigins = map[string]bool{
	"https://app.example.com":   true,
	"https://admin.example.com": true,
}

// CORSMiddleware handles Cross-Origin Resource Sharing.
func CORSMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")

		if allowedOrigins[origin] {
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
			w.Header().Set("Access-Control-Max-Age", "3600")
			w.Header().Set("Access-Control-Allow-Credentials", "true")
		}

		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		next.ServeHTTP(w, r)
	})
}
## API Keys vs Tokens
API Key JWT Token
Кто использует Сервисы, приложения Пользователи
Срок жизни Долгий (месяцы) Короткий (минуты)
Информация Только идентификатор Claims (user, roles)
Revocation Удалить из БД Blacklist / wait expiry
Подходит для Server-to-server User-facing API

Security Headers

PHPGo
<?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=()');
    }
}
package middleware

import "net/http"

// SecurityHeadersMiddleware adds security headers to every response.
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("X-XSS-Protection", "1; mode=block")
		w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
		w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'")
		w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
		w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")

		next.ServeHTTP(w, r)
	})
}
## Итоги
Концепция Суть
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