docs: require the trusted-proxy switch for the rate-limit source (#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 469aa3f7bd
commit c12ecad3e3
3 changed files with 77 additions and 11 deletions
+12 -4
View File
@@ -43,6 +43,13 @@
потому, что цена ошибки здесь — обход частотного лимита сменой одной строки в
запросе, и полагаться на умолчания чужого конфига для этого нельзя.
Заголовок читается только при включённом `HP_RELAY_TRUSTED_PROXY` (умолчание —
включён). Со снятым переключателем relay игнорирует заголовок и берёт адрес
соединения: это верно для узла, выставленного в интернет напрямую, и **запрещено
для узла за прокси** — там все соединения приходят от прокси, и весь публичный
эндпоинт делил бы одну корзину лимита на всех. Оба продовых инстанса стоят за
Caddy, поэтому у обоих переключатель включён.
Сообщение и контакт нормализуются и очищаются от управляющих символов, включая
маркеры двунаправленного письма: доставка выводит их буквальным текстом без
разметки, поэтому подделать вид сообщения нельзя.
@@ -196,12 +203,13 @@ 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`, подменить `source` в вебхуке, приложить пакет к вебхуку)
роняют ровно те проверки, ради которых написаны.
`X-Forwarded-For`, подменить `source` в вебхуке, приложить пакет к вебхуку,
читать заголовок независимо от переключателя доверия прокси) роняют ровно те
проверки, ради которых написаны.
+51
View File
@@ -309,6 +309,57 @@ class RelayTestCase(unittest.TestCase):
self.assertEqual(config.RATE_TTL_SECONDS, 24 * 3600)
class DirectNodeTestCase(unittest.TestCase):
"""Узел без прокси: заголовку верить нельзя, потому что подставить его некому."""
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
cfg = config.load({
"HP_RELAY_SPOOL": str(Path(self._tmp.name) / "s"),
"HP_RELAY_TRUSTED_PROXY": "0",
})
self.service = app.Service(cfg)
self.service.delivery = RecordingDelivery()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), app.make_handler(self.service))
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self._tmp.cleanup()
def _post(self, key: str, forwarded: str | None) -> int:
blob = package_bytes()
content_type, body = build_body(request_json(blob, key=key), blob)
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/v1/reports", data=body, method="POST",
)
request.add_header("Content-Type", content_type)
if forwarded:
request.add_header("X-Forwarded-For", forwarded)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return response.status
except urllib.error.HTTPError as error:
return error.code
def test_direct_node_ignores_the_forwarded_header(self):
"""Со снятым переключателем заголовок не участвует вовсе.
Иначе узел, стоящий в интернете напрямую, доверял бы строке, которую
полностью пишет вызывающий, — то есть каждый выбирал бы себе корзину сам.
"""
self.assertEqual(self._post("direct-key-0001", "203.0.113.10"), 200)
self.assertEqual(self._post("direct-key-0002", "198.51.100.20"), 200)
self.assertEqual(self._post("direct-key-0003", None), 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)
class WebhookChannelTestCase(unittest.TestCase):
"""Канал «через Home Assistant»: что уходит и что остаётся."""