From d94f6f73b2cd72b18b8c0f2658260fcaf70eb4bd Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sun, 16 Aug 2026 19:11:56 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B2=D0=B5=D0=B1=D1=85=D1=83=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B8=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отпечаток секрета (sha256, 8 символов) в /health по токену: две стороны сверяют настройки, не пересылая значение. В журнал доставки добавлены размер тела, его sha256, начало подписи и текст ответа - по ним видно, расходится секрет или принимающая сторона считает подпись не от сырых байтов. Co-Authored-By: Claude Opus 5 (1M context) --- app/main.py | 9 ++++++++- app/webhook.py | 33 ++++++++++++++++++++++++++++---- tests/test_webhook.py | 44 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/app/main.py b/app/main.py index 39ee7ad..cd239d8 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,7 @@ from app.pipeline import ModelsMissing, Pipeline, to_wav16k from app.security import (check_token, client_address, ip_allowed, parse_allowlist, token_matches) from app.store import JobStatus, JobStore -from app.webhook import deliver_async +from app.webhook import deliver_async, secret_fingerprint from app.version import __version__ log = logging.getLogger("talkscore-asr") @@ -305,6 +305,13 @@ def health(request: Request) -> JSONResponse: "your_ip": client_ip, "your_ip_allowed": ip_allowed(client_ip, allowlist), "ip_filter_active": bool(allowlist), + "webhook_secret_set": bool(settings.webhook_secret), + # Отпечаток, а не сам секрет: две стороны сверяют его и видят, + # совпадают ли настройки, не пересылая значение. Отдаётся только + # по токену - для мониторинга он не нужен. + **({"webhook_secret_fingerprint": secret_fingerprint(settings.webhook_secret)} + if check_token(request.headers.get("authorization"), settings.token) + else {}), }) diff --git a/app/webhook.py b/app/webhook.py index d49a072..d32ff97 100644 --- a/app/webhook.py +++ b/app/webhook.py @@ -11,7 +11,7 @@ import logging import threading import time -__all__ = ["sign_payload", "deliver", "deliver_async"] +__all__ = ["sign_payload", "deliver", "deliver_async", "secret_fingerprint"] log = logging.getLogger(__name__) @@ -31,10 +31,22 @@ def deliver(url: str, payload: dict, secret: str = "", """Отправляет результат, повторяя при неудаче. Возвращает признак успеха.""" import requests + # ensure_ascii=False: кириллица уходит живыми UTF-8 байтами. Принимающая + # сторона обязана считать подпись от сырого тела запроса - если она + # пересоберёт JSON из разобранного объекта, байты изменятся и подпись + # не сойдётся ни при каком секрете. body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers = {"Content-Type": "application/json; charset=utf-8"} - if secret: - headers["X-Talkscore-Signature"] = sign_payload(body, secret) + signature = sign_payload(body, secret) if secret else "" + if signature: + headers["X-Talkscore-Signature"] = signature + + # Ни подпись, ни хеш тела секретом не являются: по ним ничего не + # восстанавливается, зато обе стороны могут сверить, об одном ли теле речь. + log.info("вебхук по задаче %s: тело %d байт, sha256 %s, подпись %s", + payload.get("job_id"), len(body), + hashlib.sha256(body).hexdigest()[:12], + signature[:16] or "НЕ ПОДПИСАНО (секрет не задан)") for attempt, delay in enumerate(delays, start=1): if delay: @@ -44,7 +56,8 @@ def deliver(url: str, payload: dict, secret: str = "", if response.status_code < 300: log.info("вебхук доставлен по задаче %s", payload.get("job_id")) return True - log.warning("вебхук: попытка %d, ответ %s", attempt, response.status_code) + log.warning("вебхук: попытка %d, ответ %s %s", attempt, + response.status_code, response.text[:200]) except Exception as exc: # noqa: BLE001 - причина неважна, важна повторная попытка log.warning("вебхук: попытка %d не удалась (%s)", attempt, exc) log.error("вебхук не доставлен по задаче %s, результат остаётся в очереди", @@ -57,3 +70,15 @@ def deliver_async(url: str, payload: dict, secret: str = "") -> None: thread = threading.Thread(target=deliver, args=(url, payload, secret), name="webhook", daemon=True) thread.start() + + +def secret_fingerprint(secret: str) -> str: + """Короткий отпечаток секрета для сверки настроек между сторонами. + + Значение пересылать нельзя - оно осядет в переписке и журналах. Отпечаток + отвечает на единственный нужный вопрос: одинаковый ли секрет настроен + здесь и там. Восстановить по нему секрет нельзя. + """ + if not secret: + return "" + return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:8] diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 633491a..3332de2 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -5,7 +5,7 @@ import json import pytest -from app.webhook import deliver, sign_payload +from app.webhook import deliver, sign_payload, secret_fingerprint class FakeResponse: @@ -95,3 +95,45 @@ class TestDelivery: (seen.update({"body": data}), FakeResponse(200))[1]) deliver("http://x", {"text": "русский текст"}, "", delays=(0,)) assert json.loads(seen["body"].decode("utf-8"))["text"] == "русский текст" + + +class TestDiagnostics: + """Сверять настройки надо, не пересылая секрет: он осядет в переписке.""" + + def test_fingerprint_is_stable(self): + assert secret_fingerprint("odin-i-tot-zhe") == secret_fingerprint("odin-i-tot-zhe") + + def test_different_secrets_differ(self): + assert secret_fingerprint("pervyy") != secret_fingerprint("vtoroy") + + def test_fingerprint_does_not_reveal_secret(self): + secret = "ochen-dlinnyy-sekret-32-bayta-rovno" + assert secret not in secret_fingerprint(secret) + assert len(secret_fingerprint(secret)) == 8 + + def test_empty_secret_gives_empty_fingerprint(self): + assert secret_fingerprint("") == "" + + +class TestUnsignedDelivery: + def test_no_header_without_secret(self, monkeypatch): + """Пустой секрет = заголовка нет вовсе. Принимающая сторона видит + не «неверную подпись», а её отсутствие - причины разные.""" + sent = {} + + class Response: + status_code = 200 + text = "" + + def fake_post(url, data=None, headers=None, timeout=None): + sent.update(headers or {}) + return Response() + + import app.webhook as wh + monkeypatch.setattr(wh, "requests", type("R", (), {"post": staticmethod(fake_post)}), + raising=False) + import sys + monkeypatch.setitem(sys.modules, "requests", + type("R", (), {"post": staticmethod(fake_post)})) + wh.deliver("http://example.com", {"job_id": "x"}, secret="", delays=(0,)) + assert "X-Talkscore-Signature" not in sent