rate limiter что была секретно один большой bucket

9 сентября 2026 г. devloggo

Rate limiting это одно из вещей что чувствует себя решённо момент ты пишешь token bucket и смотришь тест проходить. Мой прошёл каждый тест я написал для этого. В production, позади Caddy, каждый единственный посетитель сайта был шарящий один bucket, десять запросов в минуту, для целого сайта вместе. Не за человека. Всего.

Перезагрузка Caddy после трассировки пути запроса прыжок за прыжком

Сам limiter никогда не был bug

Реальная bucket логика это маленькая, в памяти, за-ключ token bucket, ничего не сложного, пополняется со временем до всплеска равного за-минуту ставке:

func (l *Limiter) Allow(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	b, ok := l.buckets[key]
	if !ok {
		l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
		return true
	}

	elapsed := now.Sub(b.lastSeen).Seconds()
	b.tokens += elapsed * l.rate
	if b.tokens > l.burst {
		b.tokens = l.burst
	}
	b.lastSeen = now

	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

Этот код правильный и не изменился. Bug был полностью в том что было передано как key.

Как я это заключал, и почему это выглядело хорошо локально

Наивная версия ключирует чисто на r.RemoteAddr, сырой адрес сокета что Go собственный HTTP сервер видит для подключения:

// что я первый отправил
func limiterKey(r *http.Request) string {
	return r.RemoteAddr
}

Локально, запуская всё через docker compose up на моём ноутбуке, то это точно браузер собственный адрес, 127.0.0.1 или близко, одна машина, один bucket, работает точно как ожидалось в каждом ручном тесте я запустил.

Топология позади Caddy в реальном развёртывании не это. Каждый запрос к blog сервису прибывает через frontend контейнер как внутренний обратный прокси, сам сидящий позади Caddy, сам смотрящий на интернет. RemoteAddr в blog сервисе это frontend контейнера адрес, для каждого единственного посетителя, потому что это действительно кто открыл TCP соединение к blog сервису с его точки зрения. Ключ основанный на том это не за-посетитель совсем, это за-переход, и есть ровно один переход между «каждым интернет посетителем» и «blog сервисом». Per-IP limiter построенный на том ключе это фактически сайт-всемирный limiter, и остался невидимым в каждом локальном тесте потому что локально нет proxy переходов скрывалось, bug только существует в топологии я никогда не тестировал напрямую.

Стандартный ответ, и ловушка внутри

Стандартное исправление для «адрес что я вижу это не реальный клиент» это X-Forwarded-For, заголовок какой proxies добавляют когда они переводят запрос. Слепо доверять это имеет свой собственный хорошо-известный трап хотя: X-Forwarded-For это header запроса, и любой клиент может установить на буквально что угодно прежде чем Caddy видит запрос, включая поддельное значение выбранное специфично чтобы столкнуться с чьим-то другим rate-limit bucket, или обойти свой. Ты не можешь просто прочитать и доверять, ты должна знать какую часть, если любую, твоя собственная инфраструктура реально написала.

Правило я приземлилась на: доверяй ровно одному значению, и только когда есть ровно одно значение доверять.

func clientIP(r *http.Request) string {
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" && !strings.Contains(xff, ",") {
		if ip := strings.TrimSpace(xff); ip != "" {
			return ip
		}
	}

	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

Заголовок с одним значением означает что blog сервис собственный upstream (frontend proxy, и только frontend proxy) написал это свежим. Что-либо с запятой в этом, несколько переходов связанные вместе, точно что поддельный заголовок клиента из снаружи произвёл бы, потому что реальный клиент имеет никакой более ранний переход уже было бы приложено всё прежде чем их собственное поддельное значение, поэтому та форма падает назад на RemoteAddr вместо того чтобы быть заключённым вообще.

Другая половина исправления живёт один переход раньше

Ничто из этого не работает пока что-то upstream не переписывает заголовок к одному доверенному значению вместо просто передачи что бы браузер ни отправил. Это работа frontend proxy, и она делает то намеренно, не случайно:

const inboundXff = event.request.headers.get('x-forwarded-for');
let clientAddress = '';
try {
	clientAddress = event.getClientAddress();
} catch {
	// нет доверенного адреса клиента для этого запроса (например прямой
	// loopback healthcheck обходящий Caddy) - падение через к отправке ничего
}
const forwardedFor = resolveForwardedFor(inboundXff, clientAddress);
if (forwardedFor) headers.set('x-forwarded-for', forwardedFor);

resolveForwardedFor берёт последнюю запись что бы ни пришло (тот что Caddy сам добавил, доверенный независимо что клиент добавил раньше в цепи), или падает назад на frontend собственный разрешённый адрес клиента, и всегда излучает ровно одно значение вперёд. Blog сервис проверка одного значения только держится потому что этот более ранний переход гарантирует это, никогда не переводя много-значение цепь. Два сервиса, два маленьких куска логики, и целая вещь работает только потому что каждый доверяет слою непосредственно позади и ничему дальше чем то.

Что реально это поднял

Не доклад от злого пользователя получившего неправедно rate-limited, что это что я ожидал в итоге будет флаговаю. Это поднялось во время прохода ревью работая через логин и пароль-измени endpoints, трассируя через точно кто может видеть какой адрес на каждом переходе, и осознав что цифра «10 запросов в минуту» верна, просто верна для неправильной популяции. Раз ты говоришь это вслух, «каждый посетитель сайта делит один bucket», это звучит очевидно сломанным. Добраться туда потребовало реально рисования пути запроса переход за переходом вместо доверия что rate limiter какой прошёл его unit тесты делает работу в топологии что это действительно будет работать.

Одно и то же исправление, дважды

Blog сервис не единственное место эта точная форма bug жила. Auth сервис держит собственный раздельный копию того же самого пакета limiter вместо разделения модуля с blog сервисом (эта целая кодовая база дублирует несколько маленьких вещей вроде того цели, JWT claims структура другой, вместо того чтобы вытягивать в разделённый внутренний пакет для дюжины строк кода), и его логин и пароль-измени endpoints имели идентичную проблему один-переход-позади-Caddy, заключённые то же самое неправильным путём по той же причине. Исправление одного и не другого значила бы один endpoint принуждение реальный за-посетитель лимит в то время как родственник endpoint два сервиса через остался молча глобальным. Раз я поняла реальную форму bug, проверка существует ли ни где где-нибудь ещё то же самое путь запроса коснулся несколько минут. Писать этот пост потребовало намного дольше чем любое исправление.

Скучный урок под интересным

Rate limiter с правильным token bucket и неправильным ключём это не наполовину-сломан, это полностью-сломан, это просто не удаётся в направлении что выглядит как успех со стороны, запросы получают позволено или отказано, цифры движутся, ничто не выбрасывает ошибку. Это то что действительно опасно форма для этого вида bug принимать. Limiter что упал совсем был бы пойман первый раз кто-либо попал на endpoint локально. Тот что молча защищает неправильную границу продолжает работать, прямо до границы что это было действительно должно защищать получает удар кем-то кто не играет честно.

0 комментариев

Войдите , чтобы оставить комментарий.

Войти

Забыли пароль?

Нет аккаунта?