Skip to content

FraudGate ​

Решение о выдаче и исходящее уведомление сохраняются одной транзакцией Postgres. Это относится к автоматическому снятию удержания, ручной выдаче, победе и старым триггерам. Worker восстанавливает старые заявки releasing и разрешённые заявки без записи доставки, не создавая второй webhook. Сбой записи очереди откатывает решение; повтор исходной операции безопасен.

Статистика ложных срабатываний сохраняет факт ручной выдачи после перехода уведомления в успешную доставку, ошибку или очередь окончательных отказов. Смена проекта в админке сбрасывает карточку игрока и открытые формы решений.

A single anti-fraud entry point evaluated before progression on both ingress routes (decision D3). Located at backend/crates/api/src/services/fraud_gate/.

Contract ​

FraudGate::evaluate(ctx) -> FraudDecision where the decision is one of:

  • Allow
  • Flag(Vec<FraudFlag>) — event proceeds, flags persisted
  • Block { code, flags } — 403 fraud_blocked

Aggregation: first Block wins, Flags accumulate, otherwise Allow.

Pipeline (fixed order) ​

RulePurpose
OriginRuleReject embedding from unapproved origins
PlanLimitRuleEnforce plan usage (with skip-paths)
Play/Preview rate limitThrottle public endpoints
ScoreVelocityRuleDetect implausible score gains
IdempotencyRulePrevent duplicate processing
ReplayRuleBlock re-sent confirmations (enforce vs monitor)
CustomRulesRuleProject fraud_config.custom_rules

CustomRulesRule evaluates every configured custom rule and collects all that trigger, rather than returning on the first match (Task 7): a project's own rule list is unordered from the admin's point of view, so a block rule listed right after a flag rule used to never even get evaluated once the flag one triggered first — the block silently never fired no matter how clearly it matched. Any triggered block now wins over any number of triggered flags regardless of list order; all triggered rules' names are recorded in the resulting flag's detail either way.

ReplayRule's outcome check is an atomic claim (FraudStore::claim_outcome, Dragonfly SET NX), not a read of a consumed flag followed by a mark written later after the whole request finishes — two concurrent requests replaying the same session/nonce used to both read "not consumed yet" and both pass (FRAUD-01). The claim's owner token is the request's own body fingerprint (already mandatory on FraudContext); a request that is later blocked, or whose progression::ingest_event fails before anything commits, releases its claim via FraudStore::release_outcome_claim (a Lua compare-and-delete, never a bare DEL) so a legitimate retry is not permanently locked out of a nonce that granted nothing.

Доверенный адрес и пометка по IP ​

services::fraud_gate::identity resolves the address a request is attributed to from the real TCP peer, never from X-Forwarded-For alone (FRAUD-05): middleware::client_ip::normalize_client_ip runs before any handler, rewriting that header to the single value it actually trusts — the peer itself, unless the peer is a configured TRUSTED_PROXIES CIDR, in which case the chain is walked from the right for the first untrusted hop. Every existing caller of services::origin::client_ip keeps reading the first (now-normalized) header element unchanged.

This guarantee only holds if the reverse proxy in front of api does not itself blindly trust client-supplied X-Forwarded-* headers — otherwise a peer that is legitimately inside TRUSTED_PROXIES (the Swarm overlay network) would still forward an attacker's own spoofed value untouched, and the API-side check above would trust it. start/local/infrastructure-stack.yml had exactly this gap (--entrypoints.web.forwardedHeaders.insecure=true, absent from start/web/infrastructure-stack.yml) until Task 16's attack simulation (scripts/e2e-fraud-sim.sh, scenario A4) caught it; the flag is removed, so Traefik now substitutes the address it actually observed, same as the web stack.

IpRateFlagRule (fraud_config.ip_events_per_minute, default 600/min) adds a per-address signal to widget play, game start and game command ingress — identity can be rotated at will (a fresh anonymous session any time), so this rule exists to keep a signal alive across that rotation. It only ever Flags, never Blocks, and is counted in /v1/status (ip_rate_checked_total / ip_rate_flagged_total, no addresses in the metric) — converting it into a block is a separate decision for after a week of pilot data. services::rate_limit::allow_session_issue adds a 500/day cap per project+address alongside its existing 30/minute one, since 30/minute alone allows 43,200 sessions/day from one address — enough to blow through any identity-scoped limit by just requesting a fresh identity (FRAUD-04).

Старт партии ​

FraudIngress::GameStart проверяет POST /v1/game-runs. Известный start_id разбирается до шлюза и лимит не тратит (PLATFORM §7.3). Конвейер: GameStartRateLimitRule → GamePlayerCapFlagRule → PlanLimitRule → IpRateFlagRule. Новый старт проверяет тариф через кэш плана до 60 с: превышение возвращает 402 plan_limit_exceeded без новой партии. Смена тарифа сбрасывает кэш на принявшей её реплике; другие реплики обновятся по сроку кэша. Повтор известного start_id возвращает прежнюю партию до этой проверки. Лимит старта блокирует с 429 rate_limited по ключу ratelimit:game_start:{project}:{user}:{game_id}; значение берётся из starts_per_minute дескриптора механики (match3_v1 — 10/мин).

Проверка игровой команды (два края) ​

Для партий нового поколения FraudGate работает дважды.

На входе у FraudIngress::GameCommand конвейер: GameCommandRateLimitRule → GamePlayerCapFlagRule → PlanLimitRule → IpRateFlagRule. Правила событий сюда не применяются: у хода нет ни клиентских очков, ни ключа идемпотентности заголовком. Частота ограничена по механике: ключ ratelimit:game_command:{project}:{user}:{game_id}, значение — commands_per_minute дескриптора (match3_v1 — 120/мин); превышение даёт 429 rate_limited. Origin сравнивает маршрут. Общий потолок тела 8 KiB проверяется до чтения партии, предел механики max_payload_bytes (match3_v1 — 512 Б) — после чтения; оба до FraudGate, ответ 413. Конверт, который в jsonb::text превышает 8192 Б, тоже даёт 413 без частичной записи.

Поля проекта fraud_config.game_commands_per_minute и game_starts_per_minute (по умолчанию 300 и 40) — общий потолок игрока по всем механикам: ratelimit:game_command_all:{project}:{user} и ratelimit:game_start_all:{project}:{user}. Превышение ставит флаг rate_exceeded со scope: game_commands_all | game_starts_all и никогда не отказывает. Отказ по лимиту механики общий счётчик не увеличивает.

Счётчик и его срок жизни обновляются одной атомарной операцией Dragonfly. Ключ без срока жизни получает окно при следующем запросе; существующее живое окно не продлевается. Это исключает бессрочную блокировку при истечении ключа между созданием и увеличением счётчика.

PlanLimitRule действительно проверяет план для GameCommand, а не просто присутствует в списке (Task 6, ADMIN-08): до исправления её внутренняя проверка вида входа пропускала GameCommand мимо любой проверки плана, поэтому проект, целиком идущий через игровые команды, никогда не упирался в лимит — независимо от того, насколько сильно аккаунт его превысил.

На командах и прочих игровых входах проверка тарифа кэшируется на 60 с по аккаунту; ошибки БД не кэшируются. Смена тарифа сбрасывает запись в текущем процессе, другие реплики обновятся не позднее 60 с. Шаблон в выборе игры для старта и лобби кэшируется на 30 с; отсутствующий шаблон не кэшируется. Админка и старые игровые маршруты читают шаблон свежо. Проект не кэшируется: смена конфигурации и антифрода действует на следующий запрос.

На выходе fraud_gate/game_run.rs проверяет уже рассчитанный сервером итог на инварианты, которые обязаны выполняться для любой игры:

КодЧто не сошлось
illegal_status_transitionПереход статуса, которого не бывает
sequence_not_continuousНомер команды вырос не на единицу
score_went_backwardsОчки уменьшились
moves_went_upХодов стало больше, чем было
rejected_command_spent_a_moveОтклонённая команда израсходовала ход
fact_contradicts_viewФакт не согласуется с показанным видом
terminal_fact_mismatchТерминальный статус без факта или наоборот

Нарушение здесь — ошибка сервера, а не игрока: ход не сохраняется вовсе, ответ 500 integrity_rejected. Клиентские очки в этом пути не участвуют: их не существует, счёт считает только сервер.

Счёт риска и шлюз ценности ​

Задача 7 добавляет L3 (счёт риска, risk_score.rs) и L4 (шлюз ценности, reward_gate.rs) поверх уже существующего FraudGate — это отдельный путь, проверяемый только при создании заявки на награду (services/rewards.rs::claim_reward_for_campaign), а не на каждом игровом ходу.

Счёт риска — risk_score::calculate — сумма весов последних флагов проекта/игрока (fraud_flags) с затуханием по возрасту (1.0 младше часа, 0.5 младше суток, 0.25 младше недели, иначе 0). Веса по умолчанию (playflow_core::default_flag_weight) переопределяются per-project через fraud_config.flag_weights. Запрос идёт без специального индекса — тот приходит миграцией задачи 9 — на объёмах пилота полный проход по флагам игрока в проекте допустим.

Шлюз ценности — reward_gate::decide_reward, единственная точка решения, вызываемая внутри той же транзакции, что создаёт заявку (тот же путь и для будущей награды за партию, задача 8):

  1. Потолки daily_reward_cap_per_user/daily_reward_cap_project — независимо от режима и счёта риска; при превышении — Reject.
  2. Ручной статус trusted (player_fraud_status) — побеждает расчёт целиком, Allow.
  3. Режим observe (умолчание) — считает и хранит риск, никогда не задерживает.
  4. Режим protect — риск ≥ risk_reject_threshold даёт Review; риск ≥ risk_hold_threshold даёт Hold; иначе, если само событие исхода получило Flag от FraudGate, тоже Hold (пол, независимый от накопленного риска — «пометить» не может значить «пропустить проверку права на награду»); иначе Allow.

Hold/Review не ставят уведомление в очередь доставки — постановка вынесена в rewards::dispatch_reward_notification, вызываемую только при реальной выдаче (сразу или из jobs/reward_release_queue.rs). Пороги и hold_ttl_hours настраиваются в fraud_config (админка → Fraud rules → Reward gate).

Разбор, метрики и гигиена флагов (задача 9) ​

Честный промах не флагуется. match3_v1::commands::SwapOutcome::rejected эмитит IntegritySignal только для форм отказа, недостижимых честной игрой (out_of_board, not_adjacent) — no_match, empty_cell и two_specials интерфейс никогда не отправит иначе, чем нажатием игрока, поэтому сигнала для них больше нет. Раньше это поднимало владельцу проекта незатухающее предупреждение на каждый обычный промах.

Свёртка записи флагов. FraudStore::claim_flag_dedup — SET NX EX в Dragonfly (keys::flag_dedup(project, user, rule)), 60-секундное скользящее окно: первый вызов в окне создаёт строку fraud_flags, все последующие — инкрементируют её occurrences/last_seen_at вместо новой строки. Атакующий не может задать объём записи повторами одного и того же запроса.

Жизненный цикл флага (миграция 043_fraud_flag_lifecycle.sql): weight, ingress, rule_name (сегодня — синоним flag_type: разнесение по конкретному правилу rules.rs оставлено за рамками задачи 9, см. журнал PLAN.md), run_id, ip_hash, request_id, occurrences, last_seen_at, resolution (confirmed/false_positive), resolved_by, resolved_at. Индекс fraud_flags_player_recent_idx — тот, без которого risk_score работал с задачи 7.

Запись флага никогда не проглатывает ошибку молча: gate::persist_flags и game_runs::execute_command's post-commit цикл логируют tracing::warn! при неудаче вместо let _ = ....

Очередь разбора — routes/admin_fraud.rs:

text
GET   /v1/admin/projects/{id}/fraud-flags                 — очередь/история, ?unresolved_only=&offset=
PATCH /v1/admin/projects/{id}/fraud-flags/{flag_id}       — {"resolution": "confirmed"|"false_positive"}
GET   /v1/admin/projects/{id}/reward-claims?status=held   — очередь задержанных наград (admin_promo.rs)
POST  /v1/admin/projects/{id}/reward-claims/{id}/release  — выдать; условно по текущему статусу
POST  /v1/admin/projects/{id}/reward-claims/{id}/reject   — отклонить с причиной
GET   /v1/admin/projects/{id}/players/{uid}/fraud         — риск, статус, флаги, заявки игрока
PUT   /v1/admin/projects/{id}/players/{uid}/fraud         — trusted/watch/shadow/blocked
GET   /v1/admin/projects/{id}/fraud-metrics               — три плитки + доля ложных срабатываний

Все — через admin_auth/admin_auth_write и insert_audit. «Доверять» (state: "trusted") не просто выигрывает будущий расчёт (задача 7) — он немедленно освобождает все текущие held/review заявки игрока в этом же запросе. watch/shadow/blocked требуют причину и срок; trusted — только причину. Ни один статус не выставляется автоматически.

Автоматический откат в observe — reward_gate::maybe_auto_rollback_tx, вызывается из decide_reward перед каждым решением в режиме protect: если доля подтверждённых ложных срабатываний за 30 дней (false_positive_stats — ручная выдача среди автоматически задержанных плюс доля «ложное» среди размеченных флагов) строго выше 25 %, проект переключается в observe внутри той же транзакции, что и решение, вызвавшее проверку, и это решение уже принимается по исправленному режиму. UPDATE ... WHERE fraud_mode = 'protect' делает откат идемпотентным — повторная проверка при всё ещё высокой доле не пишет второй раз в audit_logs.

Дашборд: счётчик «Fraud flags», который никогда не гас, заменён тремя плитками (fraud-metrics): «Задержано наград», «Выдано после проверки», «В очереди» (с индикатором «overdue» при возрасте старейшей задержки старше 48 часов). Уведомление в шапке (notifications::project_counters) считает только fraud_flags с resolution IS NULL.

Счётчики решений — engine_metrics::record_fraud_decision(ingress, rule, decision), процесс-локальный HashMap, выводится в /v1/status как fraud_decisions.

Retention — jobs/fraud_retention_queue.rs удаляет разобранные (resolution IS NOT NULL) флаги старше 90 дней раз в час; неразобранные не трогает никогда, какого бы возраста они ни были. Единственный владелец удаления из fraud_flags — следующая задача, которой понадобится очистка этой таблицы, должна расширить условие здесь, а не заводить второй cleaner.

Files ​

  • rules.rs — rule implementations + in-memory FraudStore (most unit tests).
  • game_run.rs — чистая проверка рассчитанного итога хода (verify_command_outcome).
  • gate.rs — evaluate aggregation tests (first-Block-wins, Flag accumulation).
  • store.rs — fraud store abstraction.
  • mod.rs — wiring + HTTP-level tests (for example apply_fraud_gate_origin_http_403).
  • risk_score.rs — затухание и агрегация счёта риска (задача 7).
  • reward_gate.rs — decide_reward/classify, единственная точка решения о выдаче награды (задача 7); false_positive_stats/maybe_auto_rollback_tx, автоматический откат в observe (задача 9).
  • ../fraud.rs (services) — record_fraud_flag со свёрткой и жизненным циклом разбора (задача 9).
  • ../../routes/admin_fraud.rs — очередь разбора, ручные статусы игрока, сводка метрик (задача 9).
  • ../../jobs/fraud_retention_queue.rs — удаление разобранных флагов по сроку хранения (задача 9).

Configuration ​

Per-project projects.fraud_config (JSONB): score velocity, auto-flag threshold, score_reconciliation_block_modes, custom rules. Validated on project PATCH and campaign create/update.

Tests ​

LobbyRead applies only per-player read limits: lobby 60/minute and reward status 30/minute, with separate keys. Allowed reads write nothing; a rejected read returns 429 and records a weak RateExceeded flag. Game-run recovery has its own 60/minute limit outside FraudGate. A locked level returns 409 and records the weak level_locked flag (default weight 5), without creating a run.

bash
cd backend
cargo test -p playflow-api fraud

Чтения и кэши игрового слоя ​

На старте после GamePlayerCapFlagRule стоит PlanLimitRule: исчерпанный план аккаунта даёт 402 plan_limit_exceeded (РП-27). Повтор известного start_id разбирается до шлюза и план не проверяет. intent: "replay" выше открытого уровня даёт 409 level_locked и слабый флаг level_locked.

Чтения лобби идут через вход LobbyRead: GET /v1/lobby — 60 в минуту, GET /v1/game-runs/{run_id}/reward — 30 в минуту на игрока. Превышение — 429 rate_limited и флаг rate_exceeded; это единственная запись в БД у этих чтений. GET /v1/game-runs/{run_id} ограничен 60 в минуту прямым счётчиком ratelimit:game_read:… без шлюза.

Кэши (G8a, задача G1.7): результат проверки плана хранится 60 с в памяти процесса — смена тарифа действует до минуты, сброс при смене тарифа — только на реплике, которая её приняла. Строка шаблона для игровых путей кешируется на 30 с — аварийное выключение game_templates.selectable срабатывает с задержкой до 30 с.

Internal & integration documentation