<?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,
) {}
}
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"`
}
// 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.
var slidingWindowScript = redis.NewScript(`
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
`)
// 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.
// key identifies the caller: user id, IP or API key.
func (l *SlidingWindowRateLimiter) Attempt(ctx context.Context, key string, maxReqs, windowSec int) (RateLimitResult, error) {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return RateLimitResult{}, fmt.Errorf("generate member id: %w", err)
}
now := float64(time.Now().UnixMicro()) / 1e6
member := fmt.Sprintf("%f:%s", now, hex.EncodeToString(b))
// Nothing is written when the limit is already reached, so there is no
// "undo" step that could delete another caller's entry.
remaining, err := slidingWindowScript.Run(
ctx, l.rdb,
[]string{"rate_limit:" + key},
now, windowSec, maxReqs, member,
).Int()
if err != nil {
return RateLimitResult{}, fmt.Errorf("evaluate rate limit script: %w", err)
}
if remaining < 0 {
return RateLimitResult{Allowed: false, Remaining: 0, RetryAfterSeconds: windowSec, Limit: maxReqs}, nil
}
return RateLimitResult{Allowed: true, Remaining: remaining, Limit: maxReqs}, nil
}
using System.Security.Cryptography;
using StackExchange.Redis;
namespace App.Security;
public readonly record struct RateLimitResult(
bool Allowed,
int Remaining,
int RetryAfterSeconds,
int Limit);
public sealed class SlidingWindowRateLimiter(IConnectionMultiplexer redis)
{
// The whole check runs as one Lua script so that trimming, counting and
// inserting are atomic — a pipeline would race between concurrent callers.
private const string Script = """
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
""";
// key identifies the caller: user id, IP or API key.
public async Task<RateLimitResult> AttemptAsync(string key, int maxRequests, int windowSeconds)
{
double now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000d;
string member = $"{now:F6}:{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))}";
RedisResult raw = await redis.GetDatabase().ScriptEvaluateAsync(
Script,
[$"rate_limit:{key}"],
[now, windowSeconds, maxRequests, member]);
int remaining = (int)raw;
return remaining < 0
? new RateLimitResult(false, 0, windowSeconds, maxRequests)
: new RateLimitResult(true, remaining, 0, maxRequests);
}
}
import secrets
import time
from dataclasses import dataclass
from redis.asyncio import Redis
# The whole check runs as one Lua script so that trimming, counting and
# inserting are atomic — a pipeline would race between concurrent callers.
_SCRIPT = """
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
"""
@dataclass(frozen=True, slots=True)
class RateLimitResult:
allowed: bool
remaining: int
retry_after_seconds: int
limit: int
class SlidingWindowRateLimiter:
def __init__(self, redis: Redis) -> None:
self._script = redis.register_script(_SCRIPT)
async def attempt(self, key: str, max_requests: int, window_seconds: int) -> RateLimitResult:
"""key identifies the caller: user id, IP or API key."""
now = time.time()
member = f"{now:.6f}:{secrets.token_hex(4)}"
remaining = int(
await self._script(
keys=[f"rate_limit:{key}"],
args=[now, window_seconds, max_requests, member],
)
)
if remaining < 0:
return RateLimitResult(
allowed=False,
remaining=0,
retry_after_seconds=window_seconds,
limit=max_requests,
)
return RateLimitResult(
allowed=True,
remaining=remaining,
retry_after_seconds=0,
limit=max_requests,
)
### 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);
}
}
package middleware
import (
"encoding/json"
"fmt"
"net"
"net/http"
"myapp/security"
)
const (
maxRequests = 100
windowSeconds = 60
)
// 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) {
// Keyed by the connection IP, never by a client-controlled header.
// RemoteAddr carries "ip:port"; the port changes per connection, so
// keying on it as-is would give every request its own bucket.
key := clientIP(r)
result, err := limiter.Attempt(r.Context(), key, maxRequests, windowSeconds)
if err != nil {
// Fail closed: an unavailable limiter must not open the gate.
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "Rate limiter unavailable"})
return
}
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)
})
}
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
using System.Globalization;
using App.Security;
using Microsoft.AspNetCore.Http;
namespace App.Middleware;
public sealed class RateLimitMiddleware(RequestDelegate next, SlidingWindowRateLimiter limiter)
{
private const int MaxRequests = 100;
private const int WindowSeconds = 60;
public async Task InvokeAsync(HttpContext context)
{
// Keyed by the connection IP, never by a client-controlled header.
string key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
RateLimitResult result = await limiter.AttemptAsync(key, MaxRequests, WindowSeconds);
context.Response.Headers["X-RateLimit-Limit"] =
result.Limit.ToString(CultureInfo.InvariantCulture);
if (!result.Allowed)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers.RetryAfter =
result.RetryAfterSeconds.ToString(CultureInfo.InvariantCulture);
context.Response.Headers["X-RateLimit-Remaining"] = "0";
await context.Response.WriteAsJsonAsync(new { error = "Too many requests" });
return;
}
context.Response.Headers["X-RateLimit-Remaining"] =
result.Remaining.ToString(CultureInfo.InvariantCulture);
await next(context);
}
}
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
MAX_REQUESTS = 100
WINDOW_SECONDS = 60
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, limiter: SlidingWindowRateLimiter) -> None:
super().__init__(app)
self._limiter = limiter
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# Keyed by the connection IP, never by a client-controlled header.
key = request.client.host if request.client else "unknown"
result = await self._limiter.attempt(key, MAX_REQUESTS, WINDOW_SECONDS)
if not result.allowed:
return JSONResponse(
{"error": "Too many requests"},
status_code=429,
headers={
"Retry-After": str(result.retry_after_seconds),
"X-RateLimit-Limit": str(result.limit),
"X-RateLimit-Remaining": "0",
},
)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(result.limit)
response.headers["X-RateLimit-Remaining"] = str(result.remaining)
return response
## 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 : [],
];
}
}
package validation
import (
"fmt"
"math"
"net/mail"
"strings"
"unicode/utf8"
)
const (
maxQuantity = 10000
maxNameLength = 255
)
// allowedFields is an allow-list — anything else is a mass-assignment attempt.
var allowedFields = map[string]bool{
"product_name": true,
"quantity": true,
"price": true,
"email": true,
}
// 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)
// Unknown fields are rejected, not silently dropped: a caller sending
// "is_admin" or "total_price" must get an error, not a partial success.
for key := range data {
if !allowedFields[key] {
errs = append(errs, fmt.Sprintf("%s is not an accepted field", key))
}
}
// 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)
// RuneCountInString counts characters, len counts bytes: a 255-byte
// limit would silently truncate non-ASCII names.
if n := utf8.RuneCountInString(name); n < 1 || n > maxNameLength {
errs = append(errs, fmt.Sprintf("product_name must be between 1 and %d characters", maxNameLength))
} else {
sanitized["product_name"] = name
}
}
// 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 qty, ok := data["quantity"].(float64); !ok || qty != math.Trunc(qty) || qty < 1 || qty > maxQuantity {
errs = append(errs, fmt.Sprintf("quantity must be an integer between 1 and %d", maxQuantity))
} else {
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"] = math.RoundToEven(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
}
}
if len(errs) > 0 {
// A half-sanitized payload must never reach the domain layer.
return ValidationResult{Valid: false, Errors: errs, Sanitized: map[string]any{}}
}
return ValidationResult{Valid: true, Sanitized: sanitized}
}
using System.ComponentModel.DataAnnotations;
namespace App.Validation;
// A typed DTO with attributes replaces manual map inspection: ASP.NET Core
// model binding rejects the request before the handler ever runs.
public sealed class CreateOrderRequest
{
[Required]
[StringLength(255, MinimumLength = 1)]
public required string ProductName { get; init; }
[Range(1, 10_000)]
public required int Quantity { get; init; }
[Range(0.01, double.MaxValue)]
public required decimal Price { get; init; }
[EmailAddress]
public string? Email { get; init; }
}
public sealed record SanitizedOrder(
string ProductName,
int Quantity,
decimal Price,
string? Email);
public static class OrderInputSanitizer
{
// Normalization that attributes cannot express: trimming and rounding.
public static SanitizedOrder Sanitize(CreateOrderRequest request)
=> new(
ProductName: request.ProductName.Trim(),
Quantity: request.Quantity,
Price: Math.Round(request.Price, 2, MidpointRounding.ToEven),
Email: request.Email?.Trim());
}
// Usage — [ApiController] returns 400 with a problem details body automatically:
// app.MapPost("/orders", (CreateOrderRequest request) =>
// Results.Ok(OrderInputSanitizer.Sanitize(request)));
from decimal import ROUND_HALF_EVEN, Decimal
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field, field_validator
class CreateOrderRequest(BaseModel):
"""A typed model replaces manual dict inspection: FastAPI rejects the
request with a 422 before the handler ever runs."""
model_config = {"str_strip_whitespace": True, "extra": "forbid"}
product_name: Annotated[str, Field(min_length=1, max_length=255)]
quantity: Annotated[int, Field(ge=1, le=10_000)]
price: Annotated[Decimal, Field(gt=0)]
email: EmailStr | None = None
@field_validator("price")
@classmethod
def round_price(cls, value: Decimal) -> Decimal:
"""Normalization that field constraints cannot express."""
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
# Usage:
# @app.post("/orders")
# async def create_order(request: CreateOrderRequest) -> OrderResponse: ...
#
# extra="forbid" also blocks mass assignment: unknown fields are an error,
# not silently accepted.
## 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');
}
}
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")
// Always advertise that the response depends on Origin, otherwise a
// shared cache can serve one origin's response to another.
w.Header().Add("Vary", "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)
})
}
namespace App.Middleware;
// ASP.NET Core ships a CORS service; a hand-written middleware is unnecessary.
public static class CorsConfiguration
{
public const string PolicyName = "api";
private static readonly string[] AllowedOrigins =
[
"https://app.example.com",
"https://admin.example.com",
];
public static IServiceCollection AddApiCors(this IServiceCollection services)
=> services.AddCors(options =>
options.AddPolicy(PolicyName, policy => policy
// An explicit origin list — never AllowAnyOrigin together
// with AllowCredentials, which the framework also rejects.
.WithOrigins(AllowedOrigins)
.WithMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.WithHeaders("Content-Type", "Authorization", "X-Request-ID")
.AllowCredentials()
// Preflight cache: 1 hour.
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
}
// Wiring — UseCors must sit before UseAuthorization:
// app.UseCors(CorsConfiguration.PolicyName);
// Preflight OPTIONS requests are answered by the middleware itself.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
]
PREFLIGHT_MAX_AGE_SECONDS = 3600 # 1 hour
def configure_cors(app: FastAPI) -> None:
"""Starlette's CORS middleware answers preflight OPTIONS itself."""
app.add_middleware(
CORSMiddleware,
# An explicit origin list — never ["*"] together with credentials,
# which would let any site read authenticated responses.
allow_origins=ALLOWED_ORIGINS,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
allow_credentials=True,
max_age=PREFLIGHT_MAX_AGE_SECONDS,
)
## 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=()');
}
}