← назад к разделу

У маркетплейса пять дверей, через которые проходят по секрету: вход покупателя и продавца по email и паролю, подтверждение заказа SMS-кодом, «забыли пароль», регистрация и смена телефона. Ни в одной нет ошибки в привычном смысле: SQL не склеивается, токен проверяется. Ломают их числом: миллион пар email-пароль из чужой утечки, миллион вариантов шестизначного кода, тысяча вопросов «есть ли такой адрес?». Защита тоже считает: сколько попыток, от кого, по какой учётке и во что обходится каждый запрос.

Обязательно

Как устроена атака и чем грозитспросят на собеседовании

В OWASP Top 10:2025 это категория A07:2025 Authentication Failures: приложение уязвимо, если допускает credential stuffing, перебор и другие автоматические атаки, «that are not quickly blocked», и разрешает заводить учётки с заведомо утёкшими паролями. В OWASP API Security Top 10 2023 та же тема разложена на два риска: API2:2023 Broken Authentication и API4:2023 Unrestricted Resource Consumption. Слабые места в коде описывают CWE-307 «Improper Restriction of Excessive Authentication Attempts», CWE-204 «Observable Response Discrepancy», CWE-208 «Observable Timing Discrepancy» и CWE-770 «Allocation of Resources Without Limits or Throttling».

Credential stuffing, password spraying и перебор

Credential Stuffing Prevention Cheat Sheet различает три атаки на пароль:

  • brute force: много паролей из словаря против одной учётки;
  • credential stuffing: пары логин-пароль из утечки другого сайта; работает, потому что люди повторяют пароли;
  • password spraying: один слабый пароль против множества учёток.

A07:2025 добавляет гибрид: пароль из утечки меняют по человеческой привычке, Winter2025 превращается в Winter2026, ILoveMyDog6 в ILoveMyDog7. Если вход ничем не тормозит автоматику, он становится «password oracle»: отвечает атакующему, верна ли пара. Для маркетплейса это украденные бонусы, заказы на чужую карту и, хуже всего, вход продавца с доступом к выплатам.

Подбор одноразового кода

Шестизначный код это 10^6 вариантов. Без лимита при тысяче запросов в секунду весь диапазон проходится за 1000 секунд, меньше 17 минут, а в среднем код находится вдвое быстрее. С лимитом в 5 попыток на код шанс угадать один код равен 5 из миллиона. Но если каждый новый код обнуляет счётчик, атакующий просто запрашивает коды по кругу. Поэтому NIST SP 800-63B (§3.1.3, внеполосные коды по SMS) требует: код не короче шести цифр из криптостойкого генератора, лимит неудачных попыток на учётку, и «Generating a new authentication secret SHALL NOT reset the failed authentication count».

Перебор учётных записей

Атакующему полезно сначала узнать, какие адреса зарегистрированы: stuffing по живым учёткам дешевле, а список покупателей сам по себе утечка. Authentication Cheat Sheet перечисляет каналы:

  • текст ошибки: «Login for User foo: invalid password» против «Login failed, invalid user ID»;
  • код ответа: 200 на успех и 403 на неудачу при одинаковой странице всё равно различимы;
  • время ответа: при «быстром выходе» несуществующий пользователь отвергается сразу, а существующий после хеширования пароля, и задержка выдаёт ответ (это и есть CWE-208);
  • «забыли пароль»: «This email address doesn't exist in our database»;
  • регистрация: «This user ID is already in use».

Расход ресурсов: SMS, выгрузки, дорогие запросы

API4:2023 напоминает, что часть ресурсов оплачивается за каждый запрос: «sending emails/SMS/phone calls, biometrics validation». Сценарий оттуда же: «забыли пароль» шлёт SMS через провайдера по $0.05 за сообщение, скрипт дёргает эндпоинт десятки тысяч раз, и компания теряет тысячи долларов за минуты. Обратная сторона та же атака на человека: Forgot Password Cheat Sheet предупреждает, что без лимита по учётке атакующий завалит почту или телефон жертвы тысячами сообщений в час. Третий вид: дорогой запрос. Продавец выгружает CSV по заказам за год, это минута работы базы; двадцать таких выгрузок параллельно кладут её для всех. Среди лимитов, отсутствие которых делает API уязвимым, API4 называет таймауты, число записей на страницу, число операций в одном запросе и лимит расходов у стороннего провайдера.

Как выглядит в коде

Типичный вход «как напишется само»: нашли пользователя, нет пользователя, вернули ошибку. Программа ниже считает вызовы функции хеширования, а не время: так разница видна без секундомера и шума сети.

живой пример

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Map;

public class LoginLeaks {
    record User(byte[] salt, byte[] hash) {}

    static int hashCalls = 0;
    static Map<String, User> users;

    static byte[] hashPassword(String password, byte[] salt) throws Exception {
        hashCalls++;
        MessageDigest sha = MessageDigest.getInstance("SHA-256");
        byte[] h = password.getBytes(StandardCharsets.UTF_8);
        for (int i = 0; i < 10_000; i++) {
            sha.update(salt);
            h = sha.digest(h);
        }
        return h;
    }

    static String login(String email, String password) throws Exception {
        User user = users.get(email);
        if (user == null) {
            return "Пользователь не найден";
        }
        if (!Arrays.equals(hashPassword(password, user.salt()), user.hash())) {
            return "Неверный пароль";
        }
        return "Вход выполнен";
    }

    public static void main(String[] args) throws Exception {
        byte[] salt = "anna-salt".getBytes(StandardCharsets.UTF_8);
        users = Map.of("anna@shop.ru", new User(salt, hashPassword("Correct-Horse-7", salt)));

        String[] seen = new String[2];
        String[] probes = {"anna@shop.ru", "ghost@shop.ru"};
        for (int i = 0; i < probes.length; i++) {
            int before = hashCalls;
            String answer = login(probes[i], "qwerty123");
            int work = hashCalls - before;
            System.out.printf("%-13s -> «%s», вызовов хеша: %d%n", probes[i], answer, work);
            seen[i] = answer + "/" + work;
        }
        System.out.println(seen[0].equals(seen[1])
                ? "перебор учёток не прошёл: ответ и работа совпадают"
                : "перебор учёток прошёл: anna@shop.ru есть в базе, ghost@shop.ru нет");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"bytes"
	"crypto/sha256"
	"fmt"
)

type User struct {
	Salt []byte
	Hash []byte
}

var hashCalls int
var users map[string]User

func hashPassword(password string, salt []byte) []byte {
	hashCalls++
	h := []byte(password)
	for i := 0; i < 10_000; i++ {
		sum := sha256.Sum256(append(append([]byte{}, salt...), h...))
		h = sum[:]
	}
	return h
}

func login(email, password string) string {
	user, ok := users[email]
	if !ok {
		return "Пользователь не найден"
	}
	if !bytes.Equal(hashPassword(password, user.Salt), user.Hash) {
		return "Неверный пароль"
	}
	return "Вход выполнен"
}

func main() {
	salt := []byte("anna-salt")
	users = map[string]User{"anna@shop.ru": {salt, hashPassword("Correct-Horse-7", salt)}}

	var seen []string
	for _, email := range []string{"anna@shop.ru", "ghost@shop.ru"} {
		before := hashCalls
		answer := login(email, "qwerty123")
		work := hashCalls - before
		fmt.Printf("%-13s -> «%s», вызовов хеша: %d\n", email, answer, work)
		seen = append(seen, fmt.Sprint(answer, "/", work))
	}
	if seen[0] == seen[1] {
		fmt.Println("перебор учёток не прошёл: ответ и работа совпадают")
	} else {
		fmt.Println("перебор учёток прошёл: anna@shop.ru есть в базе, ghost@shop.ru нет")
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const crypto = require("crypto");

let hashCalls = 0;

function hashPassword(password, salt) {
  hashCalls++;
  let h = Buffer.from(password, "utf8");
  for (let i = 0; i < 10_000; i++) {
    h = crypto.createHash("sha256").update(salt).update(h).digest();
  }
  return h;
}

const salt = Buffer.from("anna-salt");
const users = new Map([["anna@shop.ru", { salt, hash: hashPassword("Correct-Horse-7", salt) }]]);

function login(email, password) {
  const user = users.get(email);
  if (!user) return "Пользователь не найден";
  if (!hashPassword(password, user.salt).equals(user.hash)) return "Неверный пароль";
  return "Вход выполнен";
}

const seen = [];
for (const email of ["anna@shop.ru", "ghost@shop.ru"]) {
  const before = hashCalls;
  const answer = login(email, "qwerty123");
  const work = hashCalls - before;
  console.log(`${email.padEnd(13)} -> «${answer}», вызовов хеша: ${work}`);
  seen.push(`${answer}/${work}`);
}
console.log(seen[0] === seen[1]
  ? "перебор учёток не прошёл: ответ и работа совпадают"
  : "перебор учёток прошёл: anna@shop.ru есть в базе, ghost@shop.ru нет");
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import hashlib

hash_calls = 0


def hash_password(password: str, salt: bytes) -> bytes:
    global hash_calls
    hash_calls += 1
    h = password.encode()
    for _ in range(10_000):
        h = hashlib.sha256(salt + h).digest()
    return h


salt = b"anna-salt"
users = {"anna@shop.ru": (salt, hash_password("Correct-Horse-7", salt))}


def login(email: str, password: str) -> str:
    user = users.get(email)
    if user is None:
        return "Пользователь не найден"
    user_salt, user_hash = user
    if hash_password(password, user_salt) != user_hash:
        return "Неверный пароль"
    return "Вход выполнен"


seen = []
for email in ("anna@shop.ru", "ghost@shop.ru"):
    before = hash_calls
    answer = login(email, "qwerty123")
    work = hash_calls - before
    print(f"{email:<13} -> «{answer}», вызовов хеша: {work}")
    seen.append((answer, work))
print("перебор учёток не прошёл: ответ и работа совпадают" if seen[0] == seen[1]
      else "перебор учёток прошёл: anna@shop.ru есть в базе, ghost@shop.ru нет")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Две утечки сразу. Текст ответа прямо говорит, есть ли адрес в базе. А если тексты выровнять, остаётся работа: для существующего адреса считается хеш, для несуществующего нет. Настоящая функция хеширования паролей (bcrypt, Argon2, PBKDF2) специально медленная, так что разница во времени ответа заметна снаружи. Здесь её заменяет итерированный SHA-256, чтобы пример запускался без библиотек.

Одинаковый ответ и одинаковая работа при входеспросят на собеседовании

Правило из Authentication Cheat Sheet: на входе, сбросе и восстановлении пароля ответ одинаковый, «regardless of whether» неверен логин или пароль, учётки нет или она заблокирована. Одинаковыми должны быть текст, код ответа и объём работы. Для несуществующего пользователя хеш считается против заранее заготовленного фиктивного хеша с той же солью и теми же параметрами, что у настоящих:

живой пример

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Map;

public class LoginUniform {
    record User(byte[] salt, byte[] hash) {}

    static int hashCalls = 0;
    static Map<String, User> users;
    static User dummy;

    static byte[] hashPassword(String password, byte[] salt) throws Exception {
        hashCalls++;
        MessageDigest sha = MessageDigest.getInstance("SHA-256");
        byte[] h = password.getBytes(StandardCharsets.UTF_8);
        for (int i = 0; i < 10_000; i++) {
            sha.update(salt);
            h = sha.digest(h);
        }
        return h;
    }

    static String login(String email, String password) throws Exception {
        User user = users.get(email);
        User target = user != null ? user : dummy;
        byte[] actual = hashPassword(password, target.salt());
        boolean match = MessageDigest.isEqual(actual, target.hash());
        return user != null && match ? "Вход выполнен" : "Неверный email или пароль";
    }

    public static void main(String[] args) throws Exception {
        SecureRandom random = new SecureRandom();
        byte[] dummySalt = new byte[16];
        random.nextBytes(dummySalt);
        dummy = new User(dummySalt, hashPassword(Long.toHexString(random.nextLong()), dummySalt));
        byte[] salt = "anna-salt".getBytes(StandardCharsets.UTF_8);
        users = Map.of("anna@shop.ru", new User(salt, hashPassword("Correct-Horse-7", salt)));

        String[] seen = new String[3];
        String[][] probes = {{"anna@shop.ru", "qwerty123"}, {"ghost@shop.ru", "qwerty123"}, {"anna@shop.ru", "Correct-Horse-7"}};
        for (int i = 0; i < probes.length; i++) {
            int before = hashCalls;
            String answer = login(probes[i][0], probes[i][1]);
            int work = hashCalls - before;
            System.out.printf("%-13s / %-15s -> «%s», вызовов хеша: %d%n", probes[i][0], probes[i][1], answer, work);
            seen[i] = answer + "/" + work;
        }
        System.out.println(seen[0].equals(seen[1])
                ? "перебор учёток не прошёл: ответ и работа совпадают"
                : "перебор учёток прошёл: ответы различаются");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"fmt"
)

type User struct {
	Salt []byte
	Hash []byte
}

var hashCalls int
var users map[string]User
var dummy User

func hashPassword(password string, salt []byte) []byte {
	hashCalls++
	h := []byte(password)
	for i := 0; i < 10_000; i++ {
		sum := sha256.Sum256(append(append([]byte{}, salt...), h...))
		h = sum[:]
	}
	return h
}

func login(email, password string) string {
	user, ok := users[email]
	target := user
	if !ok {
		target = dummy
	}
	actual := hashPassword(password, target.Salt)
	match := subtle.ConstantTimeCompare(actual, target.Hash) == 1
	if ok && match {
		return "Вход выполнен"
	}
	return "Неверный email или пароль"
}

func main() {
	dummySalt, dummySecret := make([]byte, 16), make([]byte, 16)
	rand.Read(dummySalt)
	rand.Read(dummySecret)
	dummy = User{dummySalt, hashPassword(fmt.Sprintf("%x", dummySecret), dummySalt)}
	salt := []byte("anna-salt")
	users = map[string]User{"anna@shop.ru": {salt, hashPassword("Correct-Horse-7", salt)}}

	var seen []string
	for _, p := range [][2]string{{"anna@shop.ru", "qwerty123"}, {"ghost@shop.ru", "qwerty123"}, {"anna@shop.ru", "Correct-Horse-7"}} {
		before := hashCalls
		answer := login(p[0], p[1])
		work := hashCalls - before
		fmt.Printf("%-13s / %-15s -> «%s», вызовов хеша: %d\n", p[0], p[1], answer, work)
		seen = append(seen, fmt.Sprint(answer, "/", work))
	}
	if seen[0] == seen[1] {
		fmt.Println("перебор учёток не прошёл: ответ и работа совпадают")
	} else {
		fmt.Println("перебор учёток прошёл: ответы различаются")
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const crypto = require("crypto");

let hashCalls = 0;

function hashPassword(password, salt) {
  hashCalls++;
  let h = Buffer.from(password, "utf8");
  for (let i = 0; i < 10_000; i++) {
    h = crypto.createHash("sha256").update(salt).update(h).digest();
  }
  return h;
}

const dummySalt = crypto.randomBytes(16);
const dummy = { salt: dummySalt, hash: hashPassword(crypto.randomBytes(16).toString("hex"), dummySalt) };
const salt = Buffer.from("anna-salt");
const users = new Map([["anna@shop.ru", { salt, hash: hashPassword("Correct-Horse-7", salt) }]]);

function login(email, password) {
  const user = users.get(email);
  const target = user ?? dummy;
  const actual = hashPassword(password, target.salt);
  const match = crypto.timingSafeEqual(actual, target.hash);
  return user && match ? "Вход выполнен" : "Неверный email или пароль";
}

const seen = [];
for (const [email, password] of [["anna@shop.ru", "qwerty123"], ["ghost@shop.ru", "qwerty123"], ["anna@shop.ru", "Correct-Horse-7"]]) {
  const before = hashCalls;
  const answer = login(email, password);
  const work = hashCalls - before;
  console.log(`${email.padEnd(13)} / ${password.padEnd(15)} -> «${answer}», вызовов хеша: ${work}`);
  seen.push(`${answer}/${work}`);
}
console.log(seen[0] === seen[1]
  ? "перебор учёток не прошёл: ответ и работа совпадают"
  : "перебор учёток прошёл: ответы различаются");
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import hashlib
import hmac
import secrets

hash_calls = 0


def hash_password(password: str, salt: bytes) -> bytes:
    global hash_calls
    hash_calls += 1
    h = password.encode()
    for _ in range(10_000):
        h = hashlib.sha256(salt + h).digest()
    return h


dummy_salt = secrets.token_bytes(16)
DUMMY = (dummy_salt, hash_password(secrets.token_hex(16), dummy_salt))
salt = b"anna-salt"
users = {"anna@shop.ru": (salt, hash_password("Correct-Horse-7", salt))}


def login(email: str, password: str) -> str:
    user = users.get(email)
    target_salt, target_hash = user if user is not None else DUMMY
    actual = hash_password(password, target_salt)
    match = hmac.compare_digest(actual, target_hash)
    return "Вход выполнен" if user is not None and match else "Неверный email или пароль"


seen = []
for email, password in (("anna@shop.ru", "qwerty123"), ("ghost@shop.ru", "qwerty123"), ("anna@shop.ru", "Correct-Horse-7")):
    before = hash_calls
    answer = login(email, password)
    work = hash_calls - before
    print(f"{email:<13} / {password:<15} -> «{answer}», вызовов хеша: {work}")
    seen.append((answer, work))
print("перебор учёток не прошёл: ответ и работа совпадают" if seen[0] == seen[1]
      else "перебор учёток прошёл: ответы различаются")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Обе неудачи теперь неотличимы ни по ответу, ни по числу вызовов хеша. Сравнение хешей идёт функцией с постоянным временем: Java - MessageDigest.isEqual, Go - subtle.ConstantTimeCompare, Node - crypto.timingSafeEqual, Python - hmac.compare_digest. Фиктивный хеш считают тем же алгоритмом и с теми же параметрами, что настоящие: если у пользователей bcrypt с cost 12, а заглушка посчитана с cost 10, время снова выдаёт ответ.

Те же правила на соседних дверях. «Забыли пароль» отвечает «If that email address is in our database, we will send you an email to reset your password» и тратит одинаковое время в обоих случаях: письмо отправляют асинхронно или проходят ту же логику без быстрого выхода. Регистрация отвечает «A link to activate your account has been emailed to the address provided», а владельцу уже занятого адреса уходит письмо «вы уже зарегистрированы».

Лимиты запросов: token bucket и скользящее окноспросят на собеседовании

Одинаковый ответ не мешает перебирать, он только прячет, что именно подошло. Перебор останавливает лимит. Самый распространённый алгоритм это token bucket: у ключа есть ведро на b жетонов, каждый запрос забирает жетон, жетоны возвращаются со скоростью r в секунду, пустое ведро значит отказ. Так его описывает документация golang.org/x/time/rate: «a "token bucket" of size b, initially full and refilled at rate r tokens per second». Ведро прощает короткий всплеск (человек ошибся три раза подряд), но держит среднюю скорость.

Ниже ведро на 5 попыток с возвратом одного жетона в 12 секунд, ключ это учётка. Время передаётся параметром, поэтому пример детерминирован:

живой пример

import java.util.HashMap;
import java.util.Map;

public class LoginRateLimit {
    static class TokenBucket {
        final long capacity;
        final long refillMillis;
        long tokens;
        long lastRefill;

        TokenBucket(long capacity, long refillMillis, long now) {
            this.capacity = capacity;
            this.refillMillis = refillMillis;
            this.tokens = capacity;
            this.lastRefill = now;
        }

        void refill(long now) {
            long added = (now - lastRefill) / refillMillis;
            tokens = Math.min(capacity, tokens + added);
            lastRefill = tokens == capacity ? now : lastRefill + added * refillMillis;
        }

        boolean tryConsume(long now) {
            refill(now);
            if (tokens == 0) {
                return false;
            }
            tokens--;
            return true;
        }

        long retryAfterSeconds(long now) {
            return (lastRefill + refillMillis - now + 999) / 1000;
        }
    }

    static final Map<String, TokenBucket> buckets = new HashMap<>();

    static String attempt(String email, long now) {
        TokenBucket bucket = buckets.computeIfAbsent("login:" + email, k -> new TokenBucket(5, 12_000, now));
        return bucket.tryConsume(now) ? "пропущен" : "429, Retry-After: " + bucket.retryAfterSeconds(now);
    }

    public static void main(String[] args) {
        for (int i = 1; i <= 7; i++) {
            System.out.println("t=0s  anna попытка " + i + ": " + attempt("anna@shop.ru", 0));
        }
        System.out.println("t=0s  boris попытка 1: " + attempt("boris@shop.ru", 0));
        for (int i = 8; i <= 10; i++) {
            System.out.println("t=30s anna попытка " + i + ": " + attempt("anna@shop.ru", 30_000));
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"time"
)

type TokenBucket struct {
	capacity   int
	refillEach time.Duration
	tokens     int
	lastRefill time.Duration
}

func (b *TokenBucket) refill(now time.Duration) {
	added := int((now - b.lastRefill) / b.refillEach)
	b.tokens = min(b.capacity, b.tokens+added)
	if b.tokens == b.capacity {
		b.lastRefill = now
	} else {
		b.lastRefill += time.Duration(added) * b.refillEach
	}
}

func (b *TokenBucket) TryConsume(now time.Duration) bool {
	b.refill(now)
	if b.tokens == 0 {
		return false
	}
	b.tokens--
	return true
}

func (b *TokenBucket) RetryAfterSeconds(now time.Duration) int {
	wait := b.lastRefill + b.refillEach - now
	return int((wait + time.Second - 1) / time.Second)
}

var buckets = map[string]*TokenBucket{}

func attempt(email string, now time.Duration) string {
	key := "login:" + email
	b, ok := buckets[key]
	if !ok {
		b = &TokenBucket{capacity: 5, refillEach: 12 * time.Second, tokens: 5, lastRefill: now}
		buckets[key] = b
	}
	if b.TryConsume(now) {
		return "пропущен"
	}
	return fmt.Sprintf("429, Retry-After: %d", b.RetryAfterSeconds(now))
}

func main() {
	for i := 1; i <= 7; i++ {
		fmt.Printf("t=0s  anna попытка %d: %s\n", i, attempt("anna@shop.ru", 0))
	}
	fmt.Printf("t=0s  boris попытка 1: %s\n", attempt("boris@shop.ru", 0))
	for i := 8; i <= 10; i++ {
		fmt.Printf("t=30s anna попытка %d: %s\n", i, attempt("anna@shop.ru", 30*time.Second))
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

class TokenBucket {
  constructor(capacity, refillMs, now) {
    this.capacity = capacity;
    this.refillMs = refillMs;
    this.tokens = capacity;
    this.lastRefill = now;
  }

  refill(now) {
    const added = Math.floor((now - this.lastRefill) / this.refillMs);
    this.tokens = Math.min(this.capacity, this.tokens + added);
    this.lastRefill = this.tokens === this.capacity ? now : this.lastRefill + added * this.refillMs;
  }

  tryConsume(now) {
    this.refill(now);
    if (this.tokens === 0) return false;
    this.tokens--;
    return true;
  }

  retryAfterSeconds(now) {
    return Math.ceil((this.lastRefill + this.refillMs - now) / 1000);
  }
}

const buckets = new Map();

function attempt(email, now) {
  const key = `login:${email}`;
  if (!buckets.has(key)) buckets.set(key, new TokenBucket(5, 12_000, now));
  const bucket = buckets.get(key);
  return bucket.tryConsume(now) ? "пропущен" : `429, Retry-After: ${bucket.retryAfterSeconds(now)}`;
}

for (let i = 1; i <= 7; i++) console.log(`t=0s  anna попытка ${i}: ${attempt("anna@shop.ru", 0)}`);
console.log(`t=0s  boris попытка 1: ${attempt("boris@shop.ru", 0)}`);
for (let i = 8; i <= 10; i++) console.log(`t=30s anna попытка ${i}: ${attempt("anna@shop.ru", 30_000)}`);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

class TokenBucket:
    def __init__(self, capacity: int, refill_ms: int, now: int):
        self.capacity = capacity
        self.refill_ms = refill_ms
        self.tokens = capacity
        self.last_refill = now

    def refill(self, now: int) -> None:
        added = (now - self.last_refill) // self.refill_ms
        self.tokens = min(self.capacity, self.tokens + added)
        self.last_refill = now if self.tokens == self.capacity else self.last_refill + added * self.refill_ms

    def try_consume(self, now: int) -> bool:
        self.refill(now)
        if self.tokens == 0:
            return False
        self.tokens -= 1
        return True

    def retry_after_seconds(self, now: int) -> int:
        return -(-(self.last_refill + self.refill_ms - now) // 1000)


buckets: dict[str, TokenBucket] = {}


def attempt(email: str, now: int) -> str:
    key = f"login:{email}"
    if key not in buckets:
        buckets[key] = TokenBucket(5, 12_000, now)
    bucket = buckets[key]
    return "пропущен" if bucket.try_consume(now) else f"429, Retry-After: {bucket.retry_after_seconds(now)}"


for i in range(1, 8):
    print(f"t=0s  anna попытка {i}: {attempt('anna@shop.ru', 0)}")
print(f"t=0s  boris попытка 1: {attempt('boris@shop.ru', 0)}")
for i in range(8, 11):
    print(f"t=30s anna попытка {i}: {attempt('anna@shop.ru', 30_000)}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Шестая попытка получает отказ и подсказку, через сколько секунд появится жетон. Ведро другого покупателя не тронуто. Через 30 секунд вернулись два жетона, третий запрос снова ждёт.

Альтернатива: скользящее окно, то есть не больше N событий за последние T секунд по журналу отметок времени. Оно точнее, но хранит отметки. Фиксированное окно («5 в минуту с начала минуты») проще всех, но на стыке двух минут пропускает двойную порцию.

Ключей нужно несколько, и каждый ловит своё:

  • по учётке: «The counter of failed logins should be associated with the account itself, rather than the source IP address» (Authentication Cheat Sheet). Ловит перебор одной учётки с тысячи адресов. Email перед подсчётом нормализуют, иначе Anna@Shop.ru и anna@shop.ru получат разные ведра;
  • по IP: ловит spraying и stuffing с одного адреса по многим учёткам;
  • общий на эндпоинт и метрика неудач: ловит распределённую атаку, где каждый ключ в отдельности тих (об этом ниже в «Глубже»).

Лимит стоит до дорогой работы: сначала ведро, потом хеширование пароля, иначе перебор всё равно грузит процессор. И лимит считается в общем хранилище, а не в памяти одного экземпляра: у express-rate-limit хранилище по умолчанию MemoryStore, и при нескольких серверах счёт расходится, поэтому для них нужен внешний store; у Bucket4j для этого есть распределённые бэкенды (JCache, Hazelcast и другие).

Обвязка в фреймворках. Все четыре варианта ставят лимит по IP на POST /api/login; лимит по учётке живёт в сервисе входа, как ведро из примера выше, или, как в Node, отдельным экземпляром с ключом из тела запроса.

import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class LoginRateLimitFilter extends OncePerRequestFilter {
    private final ConcurrentHashMap<String, Bucket> bucketsByIp = new ConcurrentHashMap<>();

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !("POST".equals(request.getMethod()) && "/api/login".equals(request.getRequestURI()));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        Bucket bucket = bucketsByIp.computeIfAbsent(request.getRemoteAddr(), ip -> Bucket.builder()
                .addLimit(limit -> limit.capacity(20).refillGreedy(20, Duration.ofMinutes(1)))
                .build());
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            chain.doFilter(request, response);
            return;
        }
        long retryAfterSeconds = (probe.getNanosToWaitForRefill() + 999_999_999) / 1_000_000_000;
        response.setHeader("Retry-After", String.valueOf(retryAfterSeconds));
        response.sendError(429, "Too Many Requests");
    }
}
package api

import (
	"math"
	"net"
	"net/http"
	"strconv"
	"sync"
	"time"

	"github.com/go-chi/chi/v5"
	"golang.org/x/time/rate"
)

type IPLimiter struct {
	mu       sync.Mutex
	limiters map[string]*rate.Limiter
}

func (l *IPLimiter) forIP(ip string) *rate.Limiter {
	l.mu.Lock()
	defer l.mu.Unlock()
	lim, ok := l.limiters[ip]
	if !ok {
		lim = rate.NewLimiter(rate.Every(3*time.Second), 20)
		l.limiters[ip] = lim
	}
	return lim
}

func (l *IPLimiter) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}
		reservation := l.forIP(ip).Reserve()
		if delay := reservation.Delay(); delay > 0 {
			reservation.Cancel()
			w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(delay.Seconds()))))
			http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func Routes(auth *AuthHandler) http.Handler {
	r := chi.NewRouter()
	loginLimiter := &IPLimiter{limiters: map[string]*rate.Limiter{}}
	r.With(loginLimiter.Middleware).Post("/api/login", auth.Login)
	return r
}
const express = require("express");
const { rateLimit } = require("express-rate-limit");

const app = express();
app.use(express.json());

const loginByIp = rateLimit({
  windowMs: 60 * 1000,
  limit: 20,
  standardHeaders: "draft-8",
  legacyHeaders: false,
});

const loginByAccount = rateLimit({
  windowMs: 60 * 1000,
  limit: 5,
  standardHeaders: "draft-8",
  legacyHeaders: false,
  keyGenerator: (req) => `login:${String(req.body?.email ?? "").trim().toLowerCase()}`,
});

app.post("/api/login", loginByIp, loginByAccount, loginHandler);
from fastapi import FastAPI, Request, Response
from pydantic import BaseModel
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address, headers_enabled=True)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)


class LoginIn(BaseModel):
    email: str
    password: str


@app.post("/api/login")
@limiter.limit("20/minute")
async def login(request: Request, response: Response, body: LoginIn):
    return await auth_service.login(body.email.strip().lower(), body.password)

Bucket4j описывает себя как «a Java rate-limiting library based on the token-bucket algorithm», фильтр выше повторяет пример из её документации. У slowapi декоратор маршрута стоит над декоратором лимита, обработчик обязан принимать request, а для заголовков с headers_enabled=True ещё и response. За прокси RemoteAddr у всех четырёх это адрес прокси: адрес клиента берут из X-Forwarded-For только от своего прокси, иначе атакующий подставит в заголовок новый адрес на каждый запрос.

Блокировка учётки, задержка и CAPTCHAспросят на собеседовании

Блокировка после N неудач самая известная мера, и у неё есть цена. Authentication Cheat Sheet перечисляет параметры: порог, длительность и экспоненциальную блокировку, где пауза начинается с секунды и удваивается после каждой неудачи. Там же предупреждение: блокировкой можно устроить отказ в обслуживании, заперев чужие учётки, например все учётки продавцов накануне распродажи. Выход оттуда же: разрешить войти через «забыли пароль» даже заблокированной учётке.

NIST SP 800-63B §3.2.2 ставит верхнюю границу: не больше 100 неудачных попыток подряд на один аутентификатор учётки, и объясняет число балансом между шансом угадать шестизначный код за 100 попыток и необходимостью восстанавливать учётку. Чтобы законный владелец реже упирался в лимит, там же предлагают растущую паузу по мере приближения к лимиту («e.g., 30 seconds up to an hour»), проверку на бота и адаптивные признаки (IP, геолокация, браузер), а после успешного входа счётчик сбрасывают.

На практике для маркетплейса это выглядит так: первые несколько ошибок бесплатны, дальше растущая задержка, после порога вход только через сброс пароля, а владельцу уходит уведомление. В ответе на вход ничего из этого не видно: заблокированная учётка получает тот же «Неверный email или пароль», иначе блокировка сама становится каналом перебора.

CAPTCHA это последний рубеж, а не первый. Authentication Cheat Sheet: многие CAPTCHA решаются автоматически или отдаются на решение сервисам, поэтому её стоит считать «defense-in-depth control», который делает перебор дороже, а не предотвращает его. Показывают её после нескольких неудач или для подозрительного входа, а не всем с первой попытки.

Одноразовый код: попытки и срок жизниспросят на собеседовании

SMS-код к заказу или смене телефона проверяется по четырём правилам: счётчик попыток на конкретный код, короткий срок жизни, одноразовость и сравнение за постоянное время. NIST для внеполосных кодов требует завершить проверку в течение 10 минут и принимать код «as valid only once». API4:2023 отдельно просит ограничивать, сколько раз клиент может «validate an OTP».

живой пример

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

public class OtpAttempts {
    static class OtpChallenge {
        final byte[] code;
        final long expiresAt;
        int attemptsLeft = 5;
        boolean used = false;

        OtpChallenge(String code, long now, long ttlMillis) {
            this.code = code.getBytes(StandardCharsets.UTF_8);
            this.expiresAt = now + ttlMillis;
        }

        String verify(String input, long now) {
            if (used || now >= expiresAt) {
                return "код истёк";
            }
            if (attemptsLeft == 0) {
                return "попытки кончились, запросите новый код";
            }
            attemptsLeft--;
            if (MessageDigest.isEqual(code, input.getBytes(StandardCharsets.UTF_8))) {
                used = true;
                return "код принят";
            }
            return "неверный код";
        }
    }

    public static void main(String[] args) {
        long ttl = 5 * 60_000;
        OtpChallenge challenge = new OtpChallenge("483920", 0, ttl);
        int compared = 0, accepted = 0;
        for (int i = 0; i < 1000; i++) {
            String answer = challenge.verify(String.format("%06d", i), 1_000);
            if (answer.equals("неверный код") || answer.equals("код принят")) compared++;
            if (answer.equals("код принят")) accepted++;
        }
        System.out.println("перебор 000000..000999: сравнений " + compared + ", принято " + accepted);
        System.out.println("владелец вводит верный код: " + challenge.verify("483920", 10_000));

        OtpChallenge fresh = new OtpChallenge("715204", 60_000, ttl);
        System.out.println("новый код через 6 минут: " + fresh.verify("715204", 60_000 + 6 * 60_000));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"crypto/subtle"
	"fmt"
	"time"
)

type OtpChallenge struct {
	code         []byte
	expiresAt    time.Duration
	attemptsLeft int
	used         bool
}

func NewChallenge(code string, now, ttl time.Duration) *OtpChallenge {
	return &OtpChallenge{code: []byte(code), expiresAt: now + ttl, attemptsLeft: 5}
}

func (c *OtpChallenge) Verify(input string, now time.Duration) string {
	if c.used || now >= c.expiresAt {
		return "код истёк"
	}
	if c.attemptsLeft == 0 {
		return "попытки кончились, запросите новый код"
	}
	c.attemptsLeft--
	if subtle.ConstantTimeCompare(c.code, []byte(input)) == 1 {
		c.used = true
		return "код принят"
	}
	return "неверный код"
}

func main() {
	ttl := 5 * time.Minute
	challenge := NewChallenge("483920", 0, ttl)
	compared, accepted := 0, 0
	for i := 0; i < 1000; i++ {
		answer := challenge.Verify(fmt.Sprintf("%06d", i), time.Second)
		if answer == "неверный код" || answer == "код принят" {
			compared++
		}
		if answer == "код принят" {
			accepted++
		}
	}
	fmt.Printf("перебор 000000..000999: сравнений %d, принято %d\n", compared, accepted)
	fmt.Println("владелец вводит верный код:", challenge.Verify("483920", 10*time.Second))

	fresh := NewChallenge("715204", time.Minute, ttl)
	fmt.Println("новый код через 6 минут:", fresh.Verify("715204", time.Minute+6*time.Minute))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const crypto = require("crypto");

class OtpChallenge {
  constructor(code, now, ttlMs) {
    this.code = Buffer.from(code);
    this.expiresAt = now + ttlMs;
    this.attemptsLeft = 5;
    this.used = false;
  }

  verify(input, now) {
    if (this.used || now >= this.expiresAt) return "код истёк";
    if (this.attemptsLeft === 0) return "попытки кончились, запросите новый код";
    this.attemptsLeft--;
    const given = Buffer.from(input);
    if (given.length === this.code.length && crypto.timingSafeEqual(given, this.code)) {
      this.used = true;
      return "код принят";
    }
    return "неверный код";
  }
}

const ttl = 5 * 60_000;
const challenge = new OtpChallenge("483920", 0, ttl);
let compared = 0;
let accepted = 0;
for (let i = 0; i < 1000; i++) {
  const answer = challenge.verify(String(i).padStart(6, "0"), 1_000);
  if (answer === "неверный код" || answer === "код принят") compared++;
  if (answer === "код принят") accepted++;
}
console.log(`перебор 000000..000999: сравнений ${compared}, принято ${accepted}`);
console.log(`владелец вводит верный код: ${challenge.verify("483920", 10_000)}`);

const fresh = new OtpChallenge("715204", 60_000, ttl);
console.log(`новый код через 6 минут: ${fresh.verify("715204", 60_000 + 6 * 60_000)}`);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import hmac


class OtpChallenge:
    def __init__(self, code: str, now: int, ttl_ms: int):
        self.code = code.encode()
        self.expires_at = now + ttl_ms
        self.attempts_left = 5
        self.used = False

    def verify(self, given: str, now: int) -> str:
        if self.used or now >= self.expires_at:
            return "код истёк"
        if self.attempts_left == 0:
            return "попытки кончились, запросите новый код"
        self.attempts_left -= 1
        if hmac.compare_digest(self.code, given.encode()):
            self.used = True
            return "код принят"
        return "неверный код"


TTL = 5 * 60_000
challenge = OtpChallenge("483920", 0, TTL)
answers = [challenge.verify(f"{i:06d}", 1_000) for i in range(1000)]
compared = sum(a in ("неверный код", "код принят") for a in answers)
accepted = answers.count("код принят")
print(f"перебор 000000..000999: сравнений {compared}, принято {accepted}")
print("владелец вводит верный код:", challenge.verify("483920", 10_000))

fresh = OtpChallenge("715204", 60_000, TTL)
print("новый код через 6 минут:", fresh.verify("715204", 60_000 + 6 * 60_000))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Из тысячи вариантов до сравнения дошли пять, остальные отбиты без проверки. Третья строка показывает цену: владелец, который ввёл верный код после атаки, тоже не прошёл и должен запросить новый. Это нормально, если отправка новых кодов сама ограничена (по телефону, учётке и IP), а счётчик неудач на учётке при новом коде не обнуляется, как требует NIST. Иначе запрос нового кода превращается в бесплатный сброс лимита.

Отправка кода стоит денег, поэтому у неё свои лимиты: например, не чаще раза в минуту на номер и не больше десятка в сутки на учётку, и лимит расходов в кабинете SMS-провайдера. API4:2023: «Configure spending limits for all service providers/API integrations», а где это невозможно, хотя бы оповещения о счетах.

Утёкшие пароли и MFAспросят на собеседовании

Credential stuffing работает только с паролями, которые уже утекли, поэтому их не принимают на входе в систему. A07:2025: при регистрации и смене пароля сверять его со списками известных утечек (пример там же: haveibeenpwned.com). NIST SP 800-63B §3.1.1.2 формулирует это как обязательство: сравнивать новый пароль целиком со списком «commonly used, expected, or compromised» паролей, при совпадении требовать другой и объяснять причину. Там же: не заставлять менять пароль по расписанию, но требовать смену при признаках компрометации.

Лучшая защита от всего перечисленного выше это второй фактор. Authentication Cheat Sheet называет MFA «by far the best defense against the majority of password-related attacks» и ссылается на оценку Microsoft: MFA остановила бы 99,9% взломов учёток (это их цифра). Для маркетплейса разумный минимум: обязательная MFA для продавцов и администраторов, у которых есть выплаты и чужие данные, а для покупателей запрос второго фактора по риску. Credential Stuffing Cheat Sheet перечисляет признаки риска: новое устройство или IP, адрес из списков прокси и VPN, IP, который пробовал войти в несколько учёток, и вход, похожий на скрипт.

Лимит по стоимости и ответ 429

Считать запросы штуками мало, когда запросы стоят по-разному. Страница из 20 заказов и выгрузка за год не должны тратить один и тот же жетон. Ведро умеет брать несколько жетонов за раз (tryConsume(n) в Bucket4j, AllowN в x/time/rate), так что тяжёлый запрос списывает пропорционально цене. Рядом ставят то, что API4:2023 перечисляет как обязательные лимиты: максимальный размер страницы на сервере, таймаут выполнения, одну одновременную выгрузку на продавца (остальные в очередь) и лимит числа операций в одном запросе. Последнее важно для GraphQL: в сценарии API2:2023 вход ограничен тремя запросами в минуту, но батч из сотни мутаций login в одном запросе обходит лимит, потому что считаются запросы, а не попытки.

Отказ по лимиту отдают кодом 429. RFC 6585 §4: «The 429 status code indicates that the user has sent too many requests in a given amount of time ("rate limiting")», ответ может содержать Retry-After, а кэшировать такой ответ нельзя («MUST NOT be stored by a cache»). Как опознавать пользователя и как считать запросы, RFC оставляет серверу. Retry-After в ответе 429 говорит, сколько ждать (число секунд или дата); без него вежливый клиент гадает, а невежливый долбит в цикле. Исключение одно: на входе 429 приходит одинаково для существующих и несуществующих учёток, иначе лимит по учётке выдаст, какие адреса есть в базе. Ведро заводят на любой нормализованный email, а не только на найденный.

Как проверить

Тесты. Те же утверждения, что печатают живые примеры, только в виде тестов: для существующего и несуществующего адреса совпадают тело, код ответа и число вызовов хешера (хешер внедряется в сервис, в тесте его подменяют счётчиком). N+1-я попытка по учётке получает 429 с Retry-After, а другая учётка проходит. Верный OTP после пяти неудач отвергается, запрос нового кода не обнуляет счётчик учётки, код после срока жизни не принимается, код не принимается второй раз. Время в тестах передаётся параметром или подменяемыми часами, как в примерах, а не ожиданием.

Руками. Скрипт из пары десятков запросов на каждую дверь: вход, «забыли пароль», подтверждение OTP, отправка OTP, регистрация, выгрузка. На каждой должен появиться 429. Ответы для существующего и выдуманного адреса сравнить побайтно, включая заголовки и код.

Ревью. Вопросы к коду:

  • все ли пути входа под лимитом: веб, мобильное API, GraphQL, вход по ссылке. API2:2023 советует спросить инженеров, какие пути забыли, и считать восстановление пароля таким же входом;
  • есть ли в ветке «пользователь не найден» ранний return до хеширования;
  • где живёт счётчик: в общем хранилище или в памяти одного экземпляра;
  • откуда берётся IP за прокси;
  • чистятся ли старые ключи, или карта ведер растёт без границ;
  • пишутся ли неудачи в журнал с оповещением: A07:2025 требует «Log all failures and alert administrators» при признаках stuffing и перебора.

Типичные ошибки

  • Лимит только по IP. Распределённый stuffing идёт с тысяч адресов по одной-две попытки с каждого.
  • Лимит только по учётке без верхнего предела на эндпоинт. Spraying пробует по одному паролю на учётку и под лимит не попадает.
  • Блокировка навсегда без входа через сброс пароля: атакующий запирает чужие учётки.
  • Отдельный текст «учётная запись заблокирована» или 429 только для существующих адресов: блокировка и лимит сами раскрывают, кто есть в базе.
  • Новый SMS-код обнуляет счётчик неудач, а отправка кодов без лимита.
  • OTP без срока жизни или принимается повторно.
  • Хеширование пароля раньше проверки лимита.
  • Счётчик в памяти процесса при нескольких экземплярах: реальный лимит умножается на число реплик.
  • X-Forwarded-For берут как есть от клиента, и каждый запрос приходит «с нового адреса».
  • Лимит считает HTTP-запросы, а не попытки входа, и пропускает батч.
  • 429 без Retry-After.
Дополнительно: при первом чтении можно пропустить

Глубже: почему лимит по IP не останавливает распределённый переборрасширенное

Credential Stuffing Cheat Sheet прямо говорит, что блокировка IP останавливает только простые атаки и не должна быть «the sole or primary defense». Наборы для stuffing вроде Sentry MBA встроенно гоняют запросы через прокси-сети, так что с каждого адреса идёт мало запросов, и это обходит и списки блокировки, и лимиты по IP. Шпаргалка предлагает ступенчатый ответ вместо одного порога: смотреть и на короткие всплески, и на длинные периоды, на адреса, которые вместе с другими дают низкий, но ровный поток, на класс адреса (домашний или хостинг) и геолокацию.

Для бэкенда маркетплейса из этого следуют три вещи. Счётчик по учётке обязателен, потому что распределённый перебор одной учётки он ловит независимо от числа адресов. Нужна общая метрика: доля неудачных входов по всем учёткам за пять минут. Stuffing по чужой базе выглядит как резкий рост неудач при обычном числе успешных входов, и это повод включить CAPTCHA или второй фактор для всех подозрительных входов, а не банить адреса по одному. И владельцу учётки сообщают о входе с нового устройства: шпаргалка выделяет уведомление пользователя в отдельную меру, потому что подошедший пароль из утечки иначе заметят только по пропавшим бонусам.

Глубже: сравнение за постоянное время и где оно действительно нужнорасширенное

Обычное сравнение массивов выходит на первом несовпавшем байте, и по времени можно угадать, сколько байтов совпало. Функции из примеров устроены иначе. Документация MessageDigest.isEqual: «The calculation time depends only on the length of digesta. It does not depend on the length of digestb or the contents of digesta and digestb». subtle.ConstantTimeCompare тратит время, зависящее только от длины, но при разной длине возвращает 0 сразу. crypto.timingSafeEqual при разной длине бросает исключение, поэтому в примере с OTP длину сравнивают заранее: длина кода не секрет. hmac.compare_digest избегает «content-based short circuiting behaviour», а при разной длине может выдать длину, но не значение.

Важнее всего постоянное время там, где атакующий сравнивает свой ввод с секретом напрямую: OTP, токен сброса пароля, API-ключ, подпись HMAC или JWT (подробно в статье о JWT). При сравнении солёных хешей пароля атакующий не управляет байтами хеша, и утечка через equals там почти ничего не даёт. Зато в той же функции входа есть утечка на порядки крупнее: ранний выход без хеширования. Постоянное сравнение её не закрывает, закрывает только фиктивный хеш, поэтому в исправленном примере есть оба.

Коротко

  • Атаки на вход ломают не код, а счёт: stuffing берёт пары из чужих утечек, spraying один пароль на много учёток, перебор кода 10^6 вариантов. Это A07:2025, API2:2023, API4:2023 и CWE-307.
  • Вход, сброс пароля и регистрация отвечают одинаково: тот же текст, тот же код, та же работа. Для несуществующего пользователя хеш считается против фиктивного, сравнение за постоянное время.
  • Лимит token bucket ставят по учётке, по IP и общий на эндпоинт, до хеширования, в общем хранилище, с нормализованным ключом.
  • Блокировка защищает и вредит: растущая пауза, вход через сброс пароля, уведомление владельца. NIST ставит предел в 100 неудач подряд; CAPTCHA только дополнение.
  • OTP: пять попыток на код, срок жизни минуты, одноразовость, новый код не обнуляет счётчик учётки, отправка кодов ограничена и стоит под лимитом расходов.
  • Утёкшие пароли отклоняют при регистрации и смене, MFA обязательна хотя бы для продавцов и администраторов.
  • Лимит считает стоимость, а не только штуки: размер страницы, таймауты, одна выгрузка за раз, операции в батче. Отказ это 429 с Retry-After.

Что почитать дальше