Безопасность 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}
}
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)
})
}
}
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 контролирует, какие домены могут делать запросы к 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 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 |