fix(relay): pin the rate-limit source to the proxy-supplied address (#43)

User-Visible: no
Issue: #43
This commit is contained in:
Codex
2026-09-02 00:05:36 +00:00
committed by claude[bot]
parent 6b3703f0b5
commit 736a6c143e
4 changed files with 51 additions and 8 deletions
+18 -5
View File
@@ -36,6 +36,13 @@
сегодняшней даты, ключ живёт сутки. Штатный логгер `BaseHTTPRequestHandler`
заменён — он печатал адрес клиента.
Из `X-Forwarded-For` берётся **последний** элемент, а не первый: первый прислал
клиент, и подделать его может кто угодно, а последний проставлен ближайшим
звеном — нашим же Caddy. Caddy при этом настроен перезаписывать заголовок
целиком (`header_up X-Forwarded-For {remote_host}`). Две меры вместо одной
потому, что цена ошибки здесь — обход частотного лимита сменой одной строки в
запросе, и полагаться на умолчания чужого конфига для этого нельзя.
Сообщение и контакт нормализуются и очищаются от управляющих символов, включая
маркеры двунаправленного письма: доставка выводит их буквальным текстом без
разметки, поэтому подделать вид сообщения нельзя.
@@ -93,6 +100,11 @@ sudo systemctl enable --now hp-support-relay-purge@prod.timer hp-support-relay-p
недоступным доменом уронит сервис при рестарте, тогда как reload оставит
работать прежний.
## Ответственный
За рубильник, оповещения и ротацию секрета отвечает Sergey Matyunin
(владелец проекта).
## Runbook
**Проверить состояние**
@@ -139,10 +151,11 @@ sudo -u hprelay cat /var/lib/hp-support-relay/prod/reports/2026-09/hpr-…/repor
cd scripts/support-relay && python3 -m unittest discover -s tests -q
```
Тридцать проверок: схема, размеры, хеш, идемпотентность, частота, ретеншн,
буквальность текста, отсутствие адреса в журналах, поведение рубильника.
Каждая проверялась отрицательным прогоном — десять мутаций рабочего кода
Тридцать одна проверка: схема, размеры, хеш, идемпотентность, частота,
ретеншн, буквальность текста, отсутствие адреса в журналах, невозможность
выбрать себе корзину лимита подделкой заголовка, поведение рубильника.
Каждая проверялась отрицательным прогоном — одиннадцать мутаций рабочего кода
(снять сверку хеша, разрешить лишнюю часть, не чистить управляющие символы,
снять лимит, писать адрес в журнал, игнорировать идемпотентность, отключить
рубильник, отключить ретеншн, не проверять секции пакета) роняют ровно те
проверки, ради которых написаны.
рубильник, отключить ретеншн, не проверять секции пакета, брать первый элемент
`X-Forwarded-For`) роняют ровно те проверки, ради которых написаны.
@@ -8,12 +8,18 @@ support.houseplan.tech {
request_body {
max_size 8.7MB
}
reverse_proxy 127.0.0.1:8130
reverse_proxy 127.0.0.1:8130 {
# Заголовок ПЕРЕЗАПИСЫВАЕТСЯ, а не дополняется: иначе клиент задаёт
# первый элемент сам и выбирает себе корзину частотного лимита.
header_up X-Forwarded-For {remote_host}
}
}
support-staging.houseplan.tech {
request_body {
max_size 8.7MB
}
reverse_proxy 127.0.0.1:8131
reverse_proxy 127.0.0.1:8131 {
header_up X-Forwarded-For {remote_host}
}
}
+7 -1
View File
@@ -145,7 +145,13 @@ def make_handler(service: Service):
if service.cfg.trusted_proxy:
forwarded = self.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
# ПОСЛЕДНИЙ элемент, а не первый. Первый — тот, что прислал
# клиент, и подделать его может кто угодно; последний
# проставлен ближайшим звеном, то есть нашим же Caddy.
# Caddy настроен перезаписывать заголовок целиком, но код не
# обязан полагаться на чужой конфиг: цена ошибки здесь —
# обход частотного лимита сменой одной строки в запросе.
return forwarded.split(",")[-1].strip()
return self.client_address[0]
def do_GET(self) -> None: # noqa: N802 - имя задано базовым классом
+18
View File
@@ -377,6 +377,24 @@ class HttpSurfaceTestCase(unittest.TestCase):
self.assertNotIn("127.0.0.1", written) # адрес соединения
self.assertNotIn("198.51.100.5", written) # адрес из заголовка
def test_client_cannot_pick_its_own_rate_bucket(self):
"""Подделанный X-Forwarded-For не создаёт новый ключ источника.
Каждый запрос приходит с чужим адресом в заголовке; ключей частоты после
этого должно остаться столько же, сколько при одном источнике, иначе
лимит обходится сменой одной строки в запросе.
"""
blob = package_bytes()
for index, forged in enumerate(("203.0.113.1", "198.51.100.2", "192.0.2.3")):
content_type, body = build_body(request_json(blob, key=f"forged-key-{index:04d}"), blob)
status, _ = self.call("POST", "/v1/reports", body, content_type,
headers={"X-Forwarded-For": f"{forged}, 127.0.0.1"})
self.assertEqual(status, 200)
spool = self.service.cfg.spool
buckets = [path.name for path in (spool / "rate").glob("*.json")
if path.name != "_global.json"]
self.assertEqual(len(buckets), 1, buckets)
def test_forwarded_for_is_used_as_the_source(self):
blob = package_bytes()
content_type, body = build_body(request_json(blob), blob)