Защита от злоупотреблений
Современные веб-приложения подвергаются постоянным атакам: от массовых DDoS до тонких попыток credential stuffing. Защита — это многоуровневая система, где каждый слой отсеивает определённый тип угроз.
Ландшафт угроз
| Тип атаки | Цель | Сложность | Ущерб |
|---|---|---|---|
| DDoS (L3/L4) | Перегрузить сеть/инфраструктуру | Низкая (booter-сервисы) | Полная недоступность |
| DDoS (L7) | Перегрузить приложение запросами | Средняя | Деградация/недоступность |
| Credential stuffing | Подбор логин/пароль из утечек | Низкая | Account takeover |
| Brute force | Перебор паролей | Низкая | Account takeover |
| Web scraping | Массовое извлечение данных | Средняя | Потеря данных, нагрузка |
| Spam | Засорение форм, комментариев | Низкая | Деградация UX |
| API abuse | Превышение лимитов API | Средняя | Финансовые потери |
| Bot fraud | Автоматизация действий (click fraud, fake accounts) | Высокая | Финансовые потери |
Атаки по уровням:
┌─────────────────────────────────────────┐
│ Application Layer (L7) │
│ Credential stuffing, scraping, spam │
│ API abuse, bot fraud, brute force │
├─────────────────────────────────────────┤
│ Transport Layer (L4) │
│ SYN flood, TCP exhaustion │
├─────────────────────────────────────────┤
│ Network Layer (L3) │
│ UDP flood, ICMP flood, amplification │
└─────────────────────────────────────────┘
Rate Limiting: многоуровневая защита
Rate limiting — первая линия обороны. Он должен работать на каждом уровне инфраструктуры.
┌──────────────┐
Запрос ──────────►│ CloudFlare │ Cloud Level (L3-L7)
│ AWS WAF │ Global rate limits
└──────┬───────┘
│
┌──────▼───────┐
│ Nginx │ Infrastructure Level
│ ALB │ Per-IP rate limits
└──────┬───────┘
│
┌──────▼───────┐
│ Application │ Application Level
│ Middleware │ Per-user, per-endpoint
└──────────────┘
Application Level
На уровне приложения мы контролируем rate limiting с максимальной гранулярностью — по пользователю, по эндпоинту, по типу операции.
<?php
declare(strict_types=1);
namespace App\RateLimiter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactory;
final readonly class ApiRateLimitListener
{
public function __construct(
private RateLimiterFactory $anonymousApiLimiter,
private RateLimiterFactory $authenticatedApiLimiter,
) {}
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
$limiter = $this->resolveLimiter($request);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
$response = new Response('Too Many Requests', 429);
$response->headers->set('Retry-After', (string) $limit->getRetryAfter()->getTimestamp());
$response->headers->set('X-RateLimit-Limit', (string) $limit->getLimit());
$response->headers->set('X-RateLimit-Remaining', '0');
$event->setResponse($response);
}
}
private function resolveLimiter(Request $request): \Symfony\Component\RateLimiter\RateLimiterInterface
{
$userId = $request->attributes->get('_user_id');
if ($userId !== null) {
return $this->authenticatedApiLimiter->create((string) $userId);
}
return $this->anonymousApiLimiter->create(
$request->getClientIp() ?? 'unknown'
);
}
}
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
// RateLimiter provides per-key rate limiting.
type RateLimiter struct {
mu sync.Mutex
limiters map[string]*clientLimiter
rate rate.Limit
burst int
ttl time.Duration
}
type clientLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
// NewRateLimiter creates a rate limiter with the given requests/second and burst.
func NewRateLimiter(rps float64, burst int) *RateLimiter {
rl := &RateLimiter{
limiters: make(map[string]*clientLimiter),
rate: rate.Limit(rps),
burst: burst,
ttl: 10 * time.Minute,
}
go rl.cleanup()
return rl
}
// Allow checks if a request from the given key is allowed.
func (rl *RateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
cl, exists := rl.limiters[key]
if !exists {
cl = &clientLimiter{
limiter: rate.NewLimiter(rl.rate, rl.burst),
}
rl.limiters[key] = cl
}
cl.lastSeen = time.Now()
return cl.limiter.Allow()
}
// Middleware wraps an HTTP handler with rate limiting.
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := extractClientIP(r)
if !rl.Allow(key) {
w.Header().Set("Retry-After", "60")
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
for key, cl := range rl.limiters {
if time.Since(cl.lastSeen) > rl.ttl {
delete(rl.limiters, key)
}
}
rl.mu.Unlock()
}
}
func extractClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return xff
}
return r.RemoteAddr
}
# /etc/nginx/conf.d/rate_limit.conf
# Zone for general API requests: 10 req/s per IP
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# Zone for login attempts: 5 req/min per IP
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# Zone for registration: 3 req/min per IP
limit_req_zone $binary_remote_addr zone=register:10m rate=3r/m;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
location /api/auth/login {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
location /api/auth/register {
limit_req zone=register burst=2 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
}
WAF (Web Application Firewall)
WAF анализирует HTTP-запросы и блокирует вредоносные на основе правил. Он работает между клиентом и приложением, фильтруя SQL injection, XSS, path traversal и другие атаки.
┌────────────────┐
Клиент ────────►│ WAF │────── Разрешено ──────► Приложение
│ │
│ Правила: │
│ - SQL Injection│────── Заблокировано ──► 403 Forbidden
│ - XSS │
│ - Bot Control │
│ - Geo-block │
│ - IP reputation│
└────────────────┘
AWS WAF: конфигурация через Terraform
# AWS WAF Web ACL attached to ALB
resource "aws_wafv2_web_acl" "main" {
name = "app-waf"
description = "Main WAF for application"
scope = "REGIONAL"
default_action {
allow {}
}
# Rule 1: AWS Managed Common Rule Set (SQL injection, XSS, etc.)
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 1
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
sampled_requests_enabled = true
cloudwatch_metrics_enabled = true
metric_name = "CommonRuleSetMetric"
}
}
# Rule 2: Bot Control
rule {
name = "AWSBotControl"
priority = 2
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesBotControlRuleSet"
vendor_name = "AWS"
managed_rule_group_configs {
aws_managed_rules_bot_control_rule_set {
inspection_level = "COMMON"
}
}
}
}
visibility_config {
sampled_requests_enabled = true
cloudwatch_metrics_enabled = true
metric_name = "BotControlMetric"
}
}
# Rule 3: Geo-blocking (block specific countries)
rule {
name = "GeoBlock"
priority = 3
action {
block {}
}
statement {
geo_match_statement {
country_codes = ["CN", "RU", "KP"] # Adjust per business requirements
}
}
visibility_config {
sampled_requests_enabled = true
cloudwatch_metrics_enabled = true
metric_name = "GeoBlockMetric"
}
}
# Rule 4: Rate limiting per IP
rule {
name = "RateLimitPerIP"
priority = 4
action {
block {}
}
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
}
}
visibility_config {
sampled_requests_enabled = true
cloudwatch_metrics_enabled = true
metric_name = "RateLimitMetric"
}
}
visibility_config {
sampled_requests_enabled = true
cloudwatch_metrics_enabled = true
metric_name = "MainWAFMetric"
}
}
# Associate WAF with ALB
resource "aws_wafv2_web_acl_association" "main" {
resource_arn = aws_lb.main.arn
web_acl_arn = aws_wafv2_web_acl.main.arn
}
Ключевые managed rule groups AWS WAF
| Rule Group | Защита от |
|---|---|
| AWSManagedRulesCommonRuleSet | OWASP Top 10 (SQLi, XSS, LFI, RFI) |
| AWSManagedRulesKnownBadInputsRuleSet | Известные вредоносные паттерны |
| AWSManagedRulesSQLiRuleSet | SQL injection (расширенные правила) |
| AWSManagedRulesLinuxRuleSet | LFI для Linux серверов |
| AWSManagedRulesBotControlRuleSet | Бот-трафик (категоризация) |
| AWSManagedRulesATPRuleSet | Account takeover prevention |
Bot Detection
Боты генерируют до 40% интернет-трафика. Задача — отличить легитимных ботов (Googlebot, мониторинг) от вредоносных (скраперы, credential stuffing).
Уровни защиты от ботов
| Уровень | Метод | Эффективность | Ложные срабатывания |
|---|---|---|---|
| Базовый | User-Agent фильтрация | Низкая (~10%) | Низкие |
| Средний | JavaScript challenge | Средняя (~60%) | Средние |
| Средний | Browser fingerprinting | Средняя (~70%) | Средние |
| Продвинутый | Поведенческий анализ | Высокая (~90%) | Низкие |
| Продвинутый | ML-модели | Очень высокая (~95%) | Очень низкие |
Honeypot: скрытые ловушки для ботов
Honeypot — это скрытое поле формы, невидимое обычному пользователю, но заполняемое ботами. Простой и эффективный метод.
┌──────────────────────────────┐
│ Форма │
│ │
│ Email: [____________] │
│ Password: [____________] │
│ │
│ ┌─────────────────────┐ │
│ │ Hidden field (CSS) │ │ ← Бот заполнит это поле
│ │ website: [________] │ │ ← Человек не увидит
│ └─────────────────────┘ │
│ │
│ [Submit] │
└──────────────────────────────┘
<?php
declare(strict_types=1);
namespace App\Security;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
final readonly class HoneypotValidator
{
private const string HONEYPOT_FIELD = 'website_url';
private const string HONEYPOT_TIMESTAMP = '_form_ts';
private const int MIN_SUBMISSION_TIME_SECONDS = 3;
public function __construct(
private LoggerInterface $logger,
) {}
/**
* Validate that the request is not from a bot.
* Returns true if the request looks legitimate.
*/
public function validate(Request $request): bool
{
// Check 1: Honeypot field must be empty
$honeypotValue = $request->request->get(self::HONEYPOT_FIELD, '');
if ($honeypotValue !== '') {
$this->logger->warning('Bot detected: honeypot field filled', [
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
'honeypot_value' => $honeypotValue,
]);
return false;
}
// Check 2: Form must not be submitted too quickly (< 3 seconds)
$formTimestamp = $request->request->get(self::HONEYPOT_TIMESTAMP);
if ($formTimestamp !== null) {
$elapsed = time() - (int) $formTimestamp;
if ($elapsed < self::MIN_SUBMISSION_TIME_SECONDS) {
$this->logger->warning('Bot detected: form submitted too quickly', [
'ip' => $request->getClientIp(),
'elapsed_seconds' => $elapsed,
]);
return false;
}
}
return true;
}
/**
* Generate honeypot fields for a form.
* @return array{field_name: string, timestamp_name: string, timestamp_value: int}
*/
public function generateFields(): array
{
return [
'field_name' => self::HONEYPOT_FIELD,
'timestamp_name' => self::HONEYPOT_TIMESTAMP,
'timestamp_value' => time(),
];
}
}
package middleware
import (
"log/slog"
"net/http"
"strconv"
"time"
)
const (
honeypotField = "website_url"
honeypotTimestamp = "_form_ts"
minSubmissionTimeSec = 3
)
// HoneypotMiddleware rejects requests that fill hidden honeypot fields.
func HoneypotMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Check honeypot field — must be empty
if r.FormValue(honeypotField) != "" {
logger.Warn("bot detected: honeypot field filled",
slog.String("ip", r.RemoteAddr),
slog.String("user_agent", r.UserAgent()),
)
// Return 200 to not reveal detection
w.WriteHeader(http.StatusOK)
return
}
// Check submission timing — must be >= 3 seconds
if tsStr := r.FormValue(honeypotTimestamp); tsStr != "" {
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err == nil {
elapsed := time.Now().Unix() - ts
if elapsed < minSubmissionTimeSec {
logger.Warn("bot detected: form submitted too quickly",
slog.String("ip", r.RemoteAddr),
slog.Int64("elapsed_sec", elapsed),
)
w.WriteHeader(http.StatusOK)
return
}
}
}
next.ServeHTTP(w, r)
})
}
Продвинутые системы анализируют паттерны поведения:
| Сигнал | Человек | Бот |
|---|---|---|
| Движения мыши | Хаотичные, плавные кривые | Прямые линии или отсутствуют |
| Скорость заполнения форм | 5-30 секунд | < 1 секунды |
| Скроллинг | Неравномерный | Отсутствует или линейный |
| Паттерн навигации | Непоследовательный | Систематический обход |
| Время между запросами | Переменное | Фиксированные интервалы |
| JavaScript execution | Нормальный | Headless browser или отсутствует |
| Cookie/localStorage | Принимает | Может не поддерживать |
DDoS Mitigation
DDoS-атаки нацелены на перегрузку ресурсов. Защита строится послойно — каждый уровень фильтрует свой тип трафика.
┌─────────────────────────────────────┐
│ Anycast DNS (CloudFlare) │
│ Распределение трафика по PoP │
│ L3/L4 scrubbing (UDP/SYN flood) │
└──────────────┬──────────────────────┘
│ Clean traffic
┌──────────────▼──────────────────────┐
│ AWS Shield / CloudFlare │
│ L3/L4: volumetric attack mitigation│
│ Auto-detection, always-on │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ WAF (L7 protection) │
│ HTTP flood detection │
│ Bot filtering, rate limiting │
│ Custom rules per endpoint │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Application │
│ Per-user rate limiting │
│ Circuit breakers │
│ Auto-scaling (if legitimate spike) │
└───────────────────────────────────────┘
L3/L4 DDoS (Volumetric)
Атаки на сетевом/транспортном уровне — UDP flood, SYN flood, amplification.
| Решение | Тип | Защита |
|---|---|---|
| CloudFlare | Cloud proxy | Anycast, scrubbing centers, 300+ Tbps capacity |
| AWS Shield Standard | Cloud (бесплатно) | Автоматическая L3/L4 защита для всех AWS ресурсов |
| AWS Shield Advanced | Cloud (платно) | DDoS response team, финансовая защита, WAF credits |
| Anycast DNS | Инфраструктура | Распределение нагрузки по географическим точкам |
L7 DDoS (Application)
Атаки на уровне приложения — HTTP flood, slowloris, API abuse.
| Метод защиты | Как работает |
|---|---|
| WAF rules | Паттерны вредоносных HTTP-запросов |
| Rate limiting | Ограничение запросов per IP / per session |
| JavaScript challenge | Проверка наличия настоящего браузера |
| CAPTCHA | При подозрительной активности |
| Auto-scaling | Легитимный трафик — масштабируемся |
| Geographic blocking | Блокировка регионов-источников атаки |
Brute Force Protection
Brute force — перебор паролей. Защита комбинирует ограничения по IP и по аккаунту.
Попытка логина
│
▼
┌───────────────────┐ Заблокирован ┌─────────────────┐
│ IP Rate Limiter │────────────────────►│ 429 Too Many │
│ (5 req/min) │ │ Requests │
└───────┬───────────┘ └─────────────────┘
│ OK
▼
┌───────────────────┐ Заблокирован ┌─────────────────┐
│ Account Throttler │────────────────────►│ 423 Locked │
│ (progressive) │ │ + Email alert │
└───────┬───────────┘ └─────────────────┘
│ OK
▼
┌───────────────────┐ Неверный пароль ┌─────────────────┐
│ Auth Check │────────────────────►│ 401 + Counter │
└───────┬───────────┘ └─────────────────┘
│ OK
▼
Успешный вход
Progressive Delays (прогрессивная задержка)
| Попытка | Задержка | Действие |
|---|---|---|
| 1-3 | 0 сек | Нормальный ответ |
| 4-5 | 2 сек | Искусственная задержка |
| 6-7 | 5 сек | Задержка + CAPTCHA |
| 8-9 | 15 сек | Задержка + CAPTCHA + уведомление |
| 10+ | Блокировка | Временная блокировка 30 мин + email |
<?php
declare(strict_types=1);
namespace App\Security;
use Psr\Log\LoggerInterface;
final class LoginThrottler
{
private const int MAX_ATTEMPTS_BY_IP = 20;
private const int MAX_ATTEMPTS_BY_ACCOUNT = 10;
private const int LOCKOUT_DURATION_SECONDS = 1800; // 30 minutes
private const string PREFIX_IP = 'login_throttle:ip:';
private const string PREFIX_ACCOUNT = 'login_throttle:account:';
/** @var array<int, int> Attempt number => delay in seconds */
private const array PROGRESSIVE_DELAYS = [
4 => 2,
6 => 5,
8 => 15,
];
public function __construct(
private readonly \Redis $redis,
private readonly LoggerInterface $logger,
) {}
/**
* Check if the login attempt should be allowed.
* Returns the delay in seconds (0 = no delay, -1 = blocked).
*/
public function checkAttempt(string $ip, string $email): int
{
// Check IP-based throttling
$ipKey = self::PREFIX_IP . $ip;
$ipAttempts = (int) $this->redis->get($ipKey);
if ($ipAttempts >= self::MAX_ATTEMPTS_BY_IP) {
$this->logger->warning('Login blocked: IP rate limit exceeded', [
'ip' => $ip,
'attempts' => $ipAttempts,
]);
return -1;
}
// Check account-based throttling
$accountKey = self::PREFIX_ACCOUNT . $this->hashEmail($email);
$accountAttempts = (int) $this->redis->get($accountKey);
if ($accountAttempts >= self::MAX_ATTEMPTS_BY_ACCOUNT) {
$this->logger->warning('Login blocked: account locked', [
'email' => $this->maskEmail($email),
'attempts' => $accountAttempts,
]);
return -1;
}
// Calculate progressive delay
return $this->calculateDelay($accountAttempts);
}
/**
* Record a failed login attempt.
*/
public function recordFailure(string $ip, string $email): void
{
$ipKey = self::PREFIX_IP . $ip;
$this->redis->incr($ipKey);
$this->redis->expire($ipKey, self::LOCKOUT_DURATION_SECONDS);
$accountKey = self::PREFIX_ACCOUNT . $this->hashEmail($email);
$attempts = $this->redis->incr($accountKey);
$this->redis->expire($accountKey, self::LOCKOUT_DURATION_SECONDS);
if ($attempts >= self::MAX_ATTEMPTS_BY_ACCOUNT) {
$this->logger->alert('Account locked due to brute force attempts', [
'email' => $this->maskEmail($email),
'ip' => $ip,
]);
// TODO: send notification email to the user
}
}
/**
* Reset throttle counters on successful login.
*/
public function recordSuccess(string $ip, string $email): void
{
$this->redis->del(self::PREFIX_IP . $ip);
$this->redis->del(self::PREFIX_ACCOUNT . $this->hashEmail($email));
}
private function calculateDelay(int $attempts): int
{
foreach (array_reverse(self::PROGRESSIVE_DELAYS, true) as $threshold => $delay) {
if ($attempts >= $threshold) {
return $delay;
}
}
return 0;
}
private function hashEmail(string $email): string
{
return hash('sha256', mb_strtolower($email));
}
private function maskEmail(string $email): string
{
$parts = explode('@', $email);
if (count($parts) !== 2) {
return '***';
}
$name = $parts[0];
$masked = mb_substr($name, 0, 2) . '***';
return $masked . '@' . $parts[1];
}
}
package auth
import (
"context"
"crypto/sha256"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
const (
maxAttemptsByIP = 20
maxAttemptsByAccount = 10
lockoutDuration = 30 * time.Minute
prefixIP = "login_throttle:ip:"
prefixAccount = "login_throttle:account:"
)
// progressiveDelays maps attempt count thresholds to delay in seconds.
var progressiveDelays = []struct {
threshold int
delay int
}{
{8, 15},
{6, 5},
{4, 2},
}
// LoginThrottler provides brute-force protection for login endpoints.
type LoginThrottler struct {
rdb *redis.Client
logger *slog.Logger
}
// NewLoginThrottler creates a new throttler instance.
func NewLoginThrottler(rdb *redis.Client, logger *slog.Logger) *LoginThrottler {
return &LoginThrottler{rdb: rdb, logger: logger}
}
// CheckAttempt returns the delay in seconds (0 = no delay, -1 = blocked).
func (t *LoginThrottler) CheckAttempt(ctx context.Context, ip, email string) (int, error) {
// Check IP-based throttling
ipKey := prefixIP + ip
ipAttempts, err := t.rdb.Get(ctx, ipKey).Int()
if err != nil && err != redis.Nil {
return 0, fmt.Errorf("check IP attempts: %w", err)
}
if ipAttempts >= maxAttemptsByIP {
t.logger.Warn("login blocked: IP rate limit exceeded",
slog.String("ip", ip),
slog.Int("attempts", ipAttempts),
)
return -1, nil
}
// Check account-based throttling
accountKey := prefixAccount + hashEmail(email)
accountAttempts, err := t.rdb.Get(ctx, accountKey).Int()
if err != nil && err != redis.Nil {
return 0, fmt.Errorf("check account attempts: %w", err)
}
if accountAttempts >= maxAttemptsByAccount {
t.logger.Warn("login blocked: account locked",
slog.String("email", maskEmail(email)),
slog.Int("attempts", accountAttempts),
)
return -1, nil
}
return calculateDelay(accountAttempts), nil
}
// RecordFailure increments failure counters for IP and account.
func (t *LoginThrottler) RecordFailure(ctx context.Context, ip, email string) error {
pipe := t.rdb.Pipeline()
ipKey := prefixIP + ip
pipe.Incr(ctx, ipKey)
pipe.Expire(ctx, ipKey, lockoutDuration)
accountKey := prefixAccount + hashEmail(email)
pipe.Incr(ctx, accountKey)
pipe.Expire(ctx, accountKey, lockoutDuration)
results, err := pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("record failure: %w", err)
}
// Check if account is now locked (3rd command is Incr for account)
if len(results) >= 3 {
if attempts, ok := results[2].(*redis.IntCmd); ok {
if val, _ := attempts.Result(); val >= maxAttemptsByAccount {
t.logger.Error("account locked due to brute force",
slog.String("email", maskEmail(email)),
slog.String("ip", ip),
)
}
}
}
return nil
}
// RecordSuccess resets throttle counters on successful login.
func (t *LoginThrottler) RecordSuccess(ctx context.Context, ip, email string) error {
pipe := t.rdb.Pipeline()
pipe.Del(ctx, prefixIP+ip)
pipe.Del(ctx, prefixAccount+hashEmail(email))
_, err := pipe.Exec(ctx)
return err
}
func calculateDelay(attempts int) int {
for _, pd := range progressiveDelays {
if attempts >= pd.threshold {
return pd.delay
}
}
return 0
}
func hashEmail(email string) string {
h := sha256.Sum256([]byte(email))
return fmt.Sprintf("%x", h)
}
func maskEmail(email string) string {
for i, c := range email {
if c == '@' && i >= 2 {
return email[:2] + "***" + email[i:]
}
}
return "***"
}
Credential stuffing — массовая проверка пар логин/пароль из украденных баз данных. В отличие от brute force, используются реальные учётные данные из утечек.
Методы защиты
| Метод | Описание |
|---|---|
| HIBP API check | Проверка пароля по базе утечек при регистрации |
| Impossible travel detection | Логин из NY, через 5 минут из Лондона |
| Device fingerprinting | Уведомление о входе с нового устройства |
| Credential monitoring | Мониторинг утечек на появление email-ов пользователей |
| MFA | Второй фактор делает украденный пароль бесполезным |
Проверка пароля через Have I Been Pwned (HIBP)
Пароль: "MyPassword123"
│
▼ SHA-1
Hash: "CBFDAC6008F9CAB4083784CBD1874F76618D2A97"
│
▼ Отправляем первые 5 символов
GET https://api.pwnedpasswords.com/range/CBFDA
│
▼ API возвращает ~500 суффиксов
C6008F9CAB4083784CBD1874F76618D2A97:12345 ← Совпадение! Пароль скомпрометирован
A1B2C3D4E5F6... ← Не совпадает
Техника k-anonymity: API никогда не получает полный хеш, только первые 5 символов.
Impossible Travel Detection
<?php
declare(strict_types=1);
namespace App\Security;
final readonly class ImpossibleTravelDetector
{
// Maximum plausible speed in km/h (commercial flight speed)
private const float MAX_SPEED_KMH = 900.0;
/**
* Check if travel between two logins is physically impossible.
*
* @param array{lat: float, lon: float, time: \DateTimeImmutable} $lastLogin
* @param array{lat: float, lon: float, time: \DateTimeImmutable} $currentLogin
*/
public function isImpossibleTravel(array $lastLogin, array $currentLogin): bool
{
$distanceKm = $this->haversineDistance(
$lastLogin['lat'],
$lastLogin['lon'],
$currentLogin['lat'],
$currentLogin['lon'],
);
$timeDiffHours = ($currentLogin['time']->getTimestamp() - $lastLogin['time']->getTimestamp()) / 3600;
if ($timeDiffHours <= 0) {
return true;
}
$requiredSpeedKmh = $distanceKm / $timeDiffHours;
return $requiredSpeedKmh > self::MAX_SPEED_KMH;
}
/**
* Calculate distance between two points using haversine formula.
*/
private function haversineDistance(
float $lat1,
float $lon1,
float $lat2,
float $lon2,
): float {
$earthRadiusKm = 6371.0;
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat / 2) ** 2
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2))
* sin($dLon / 2) ** 2;
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadiusKm * $c;
}
}
package security
import (
"math"
"time"
)
const (
earthRadiusKm = 6371.0
maxSpeedKmH = 900.0 // Commercial flight speed
)
// LoginLocation represents a login event with geo data.
type LoginLocation struct {
Lat float64
Lon float64
Time time.Time
}
// IsImpossibleTravel checks if travel between two logins is physically impossible.
func IsImpossibleTravel(last, current LoginLocation) bool {
distance := haversineDistance(last.Lat, last.Lon, current.Lat, current.Lon)
timeDiff := current.Time.Sub(last.Time).Hours()
if timeDiff <= 0 {
return true
}
requiredSpeed := distance / timeDiff
return requiredSpeed > maxSpeedKmH
}
func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 {
dLat := degreesToRadians(lat2 - lat1)
dLon := degreesToRadians(lon2 - lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(degreesToRadians(lat1))*math.Cos(degreesToRadians(lat2))*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusKm * c
}
func degreesToRadians(deg float64) float64 {
return deg * math.Pi / 180
}
CAPTCHA нужна не везде — только при подозрительном поведении. Излишнее использование CAPTCHA ухудшает UX.
Когда показывать CAPTCHA
| Ситуация | Действие |
|---|---|
| 3+ неудачных попыток логина | Показать CAPTCHA |
| Массовая отправка форм | Показать CAPTCHA |
| Подозрительный fingerprint | Показать CAPTCHA |
| Новая регистрация | Показать CAPTCHA |
| Обычный авторизованный пользователь | Не показывать |
| Пользователь с историей | Не показывать |
Сравнение решений
| Характеристика | reCAPTCHA v3 | hCaptcha |
|---|---|---|
| Тип | Score-based (invisible) | Challenge-based |
| Privacy | Данные идут в Google | Privacy-focused |
| Стоимость | Бесплатно (до лимита) | Бесплатно + платно |
| Accuracy | Высокая | Высокая |
| Accessibility | Хорошая | Хорошая |
| GDPR | Требует consent | Совместим |
PHP: сервис верификации CAPTCHA
<?php
declare(strict_types=1);
namespace App\Security;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class CaptchaVerifier
{
private const string RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
private const float MIN_SCORE = 0.5;
public function __construct(
private HttpClientInterface $httpClient,
private string $recaptchaSecret,
private LoggerInterface $logger,
) {}
/**
* Verify reCAPTCHA v3 token.
* Returns score (0.0 - 1.0) or null on failure.
*/
public function verify(string $token, string $expectedAction, ?string $ip = null): ?float
{
try {
$response = $this->httpClient->request('POST', self::RECAPTCHA_VERIFY_URL, [
'body' => [
'secret' => $this->recaptchaSecret,
'response' => $token,
'remoteip' => $ip,
],
]);
/** @var array{success: bool, score?: float, action?: string, error-codes?: list<string>} $data */
$data = $response->toArray();
if (!$data['success']) {
$this->logger->warning('CAPTCHA verification failed', [
'errors' => $data['error-codes'] ?? [],
]);
return null;
}
// Verify action matches expected
if (isset($data['action']) && $data['action'] !== $expectedAction) {
$this->logger->warning('CAPTCHA action mismatch', [
'expected' => $expectedAction,
'actual' => $data['action'],
]);
return null;
}
return $data['score'] ?? null;
} catch (\Throwable $e) {
$this->logger->error('CAPTCHA verification error', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Check if CAPTCHA score is acceptable.
*/
public function isHuman(string $token, string $action, ?string $ip = null): bool
{
$score = $this->verify($token, $action, $ip);
return $score !== null && $score >= self::MIN_SCORE;
}
}
Audit Logging для событий безопасности
Audit log — неизменяемая запись всех важных событий безопасности. Позволяет расследовать инциденты и обнаруживать аномалии.
Что логировать
| Категория | События |
|---|---|
| Аутентификация | Login success/failure, logout, password change, MFA enable/disable |
| Авторизация | Permission granted/denied, role change |
| Данные | Create, read, update, delete sensitive data |
| Конфигурация | Settings change, feature toggle, deploy |
| Безопасность | Rate limit triggered, CAPTCHA failure, blocked IP |
| Административные | User create/delete, permission change, API key rotation |
Структура audit log записи
{
"timestamp": "2026-03-01T14:32:15.123Z",
"event_type": "auth.login.failure",
"actor": {
"type": "user",
"id": "usr_abc123",
"ip": "192.168.1.100",
"user_agent": "Mozilla/5.0..."
},
"target": {
"type": "account",
"id": "usr_abc123"
},
"action": "login",
"outcome": "failure",
"reason": "invalid_password",
"metadata": {
"attempt_number": 3,
"geo": {"country": "RU", "city": "Moscow"},
"device_fingerprint": "fp_xyz789"
}
}
PHP: SecurityAuditLogger
<?php
declare(strict_types=1);
namespace App\Security\Audit;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class SecurityAuditLogger
{
public function __construct(
private LoggerInterface $auditLogger, // Monolog channel: "audit"
private RequestStack $requestStack,
) {}
/**
* Log an authentication event.
*/
public function logAuthEvent(
string $eventType,
string $outcome,
?string $userId = null,
array $metadata = [],
): void {
$request = $this->requestStack->getCurrentRequest();
$this->auditLogger->info('Security audit event', [
'event_type' => $eventType,
'outcome' => $outcome,
'actor' => [
'user_id' => $userId,
'ip' => $request?->getClientIp(),
'user_agent' => $request?->headers->get('User-Agent'),
],
'metadata' => $metadata,
'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339_EXTENDED),
]);
}
/**
* Log a data access event.
*/
public function logDataAccess(
string $action,
string $resourceType,
string $resourceId,
string $userId,
array $metadata = [],
): void {
$request = $this->requestStack->getCurrentRequest();
$this->auditLogger->info('Data access audit', [
'event_type' => "data.{$action}",
'outcome' => 'success',
'actor' => [
'user_id' => $userId,
'ip' => $request?->getClientIp(),
],
'target' => [
'type' => $resourceType,
'id' => $resourceId,
],
'metadata' => $metadata,
'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339_EXTENDED),
]);
}
/**
* Log a security violation event.
*/
public function logSecurityViolation(
string $violationType,
string $description,
?string $userId = null,
array $metadata = [],
): void {
$request = $this->requestStack->getCurrentRequest();
$this->auditLogger->warning('Security violation', [
'event_type' => "security.{$violationType}",
'outcome' => 'blocked',
'actor' => [
'user_id' => $userId,
'ip' => $request?->getClientIp(),
'user_agent' => $request?->headers->get('User-Agent'),
],
'description' => $description,
'metadata' => $metadata,
'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339_EXTENDED),
]);
}
}
Go: Audit Middleware
package middleware
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
)
// AuditEvent represents a security audit log entry.
type AuditEvent struct {
Timestamp time.Time `json:"timestamp"`
EventType string `json:"event_type"`
Action string `json:"action"`
Outcome string `json:"outcome"`
ActorID string `json:"actor_id,omitempty"`
ActorIP string `json:"actor_ip"`
UserAgent string `json:"user_agent"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// AuditLogger writes security audit events to a structured log.
type AuditLogger struct {
logger *slog.Logger
}
// NewAuditLogger creates a new audit logger instance.
func NewAuditLogger(logger *slog.Logger) *AuditLogger {
return &AuditLogger{
logger: logger.WithGroup("audit"),
}
}
// Log writes an audit event.
func (a *AuditLogger) Log(ctx context.Context, event AuditEvent) {
event.Timestamp = time.Now().UTC()
data, _ := json.Marshal(event)
a.logger.InfoContext(ctx, "audit_event",
slog.String("event_type", event.EventType),
slog.String("outcome", event.Outcome),
slog.String("actor_ip", event.ActorIP),
slog.String("raw", string(data)),
)
}
// AuditMiddleware logs all incoming HTTP requests for security auditing.
func AuditMiddleware(audit *AuditLogger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wrap ResponseWriter to capture status code
wrapped := &statusWriter{ResponseWriter: w, statusCode: http.StatusOK}
start := time.Now()
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
outcome := "success"
if wrapped.statusCode >= 400 {
outcome = "failure"
}
audit.Log(r.Context(), AuditEvent{
EventType: "http.request",
Action: r.Method + " " + r.URL.Path,
Outcome: outcome,
ActorIP: r.RemoteAddr,
UserAgent: r.UserAgent(),
Metadata: map[string]string{
"status": http.StatusText(wrapped.statusCode),
"duration": duration.String(),
"method": r.Method,
"path": r.URL.Path,
},
})
})
}
type statusWriter struct {
http.ResponseWriter
statusCode int
}
func (w *statusWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
Хранение audit logs
| Требование | Реализация |
|---|---|
| Неизменяемость | Append-only хранилище, нет операций UPDATE/DELETE |
| Целостность | Хеш-цепочка (каждая запись содержит хеш предыдущей) |
| Долгосрочность | Минимум 1 год хранения, архивация в S3 Glacier |
| Доступность | Поиск по actor, event_type, time range |
| Защита | Отдельные permissions, шифрование at rest |
| Мониторинг | Алерты при аномалиях (массовые failures, admin actions) |
Abuse Detection Pipeline
Все компоненты защиты работают как единый конвейер. Каждый слой фильтрует определённый тип угрозы, пропуская дальше только чистый трафик.
Входящий запрос
│
┌────────────▼────────────┐
│ CloudFlare/CDN │ L3/L4 DDoS mitigation
│ IP reputation check │ Anycast, scrubbing
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ WAF │ L7 attack filtering
│ SQL injection, XSS │ Bot control rules
│ Managed rule sets │ Geo-blocking
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Rate Limiter │ Per-IP, per-endpoint
│ (nginx / ALB) │ Burst protection
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Bot Detection │ Honeypot check
│ (Application) │ Fingerprint validation
│ │ Behavioral analysis
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ App Rate Limiter │ Per-user limits
│ Brute force check │ Progressive delays
│ CAPTCHA (if needed) │ Account lockout
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Application Logic │ Business rules
│ Feature gating │ Authorization
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Audit Log │ Who, what, when
│ Security events │ Append-only storage
│ Anomaly detection │ Alert pipeline
└─────────────────────────┘
Ключевые принципы пайплайна
| Принцип | Описание |
|---|---|
| Defence in depth | Каждый слой независим — отказ одного не критичен |
| Fail closed | При сбое WAF — блокируем, а не пропускаем |
| Graceful degradation | Возврат 429 вместо 503 при перегрузке |
| Observability | Каждый слой пишет метрики и логи |
| Tuning | Регулярный анализ false positives / false negatives |