From fd00245cc08966582ef244c1d280f3a0875af11a Mon Sep 17 00:00:00 2001 From: Drexuxux Date: Thu, 16 Jul 2026 04:42:13 +0300 Subject: [PATCH 1/2] fix(webhook): reject a non-ASCII signature header instead of crashing the endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _validate_signature backs the public webhook receiver. It compared each attacker-supplied signature/token header (GitHub X-Hub-Signature-256, GitLab X-Gitlab-Token, generic X-Webhook-Signature / -V2, and the Svix v1 header) against a computed hex/base64 digest with hmac.compare_digest on two str values. compare_digest raises TypeError on a str containing non-ASCII characters, and the header is raw client input on an unauthenticated endpoint — so any internet client could POST a single non-ASCII byte in the signature header and raise out of the handler, returning a 500 instead of a clean 401. Fail-closed, but an on-demand crash of the request path. Route all five comparisons through a small _hmac_str_equal() helper that encodes both sides to UTF-8 bytes before the constant-time compare (compare_digest has no ASCII restriction on bytes). Semantics are unchanged for valid signatures; a hostile non-ASCII header now fails closed with a rejection instead of raising. Adds regression tests: non-ASCII GitHub/GitLab/generic/V2 signature headers return False (no raise), and a non-ASCII configured secret still matches its exact token value. Also maps drexux0@gmail.com in scripts/release.py AUTHOR_MAP. --- gateway/platforms/webhook.py | 24 +++++++++++++++---- scripts/release.py | 1 + tests/gateway/test_webhook_adapter.py | 34 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index e2e52a174f73..ad05aeb2ddad 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -106,6 +106,20 @@ def _is_loopback_host(host: str) -> bool: return host.strip().lower() in _LOOPBACK_HOSTS +def _hmac_str_equal(provided: str, expected: str) -> bool: + """Timing-safe equality for two ``str`` values, tolerant of non-ASCII input. + + ``hmac.compare_digest`` raises ``TypeError`` when given a ``str`` that + contains non-ASCII characters. The ``provided`` value here is an + attacker-controlled signature/token header on a public, unauthenticated + webhook endpoint, so a single non-ASCII byte would otherwise raise out of + the request handler and return a 500 instead of rejecting the request. + Comparing as UTF-8 bytes keeps the constant-time guarantee while making a + hostile header fail closed with a clean rejection. + """ + return hmac.compare_digest(provided.encode(), expected.encode()) + + def check_webhook_requirements() -> bool: """Check if webhook adapter dependencies are available.""" return AIOHTTP_AVAILABLE @@ -927,12 +941,12 @@ def _header(name: str) -> str: expected = "sha256=" + hmac.new( secret.encode(), body, hashlib.sha256 ).hexdigest() - return hmac.compare_digest(gh_sig, expected) + return _hmac_str_equal(gh_sig, expected) # GitLab: X-Gitlab-Token = gl_token = request.headers.get("X-Gitlab-Token", "") if gl_token: - return hmac.compare_digest(gl_token, secret) + return _hmac_str_equal(gl_token, secret) # Generic V2: X-Webhook-Signature-V2 = ."> # X-Webhook-Timestamp = (required for V2) @@ -975,7 +989,7 @@ def _header(name: str) -> str: expected_v2 = hmac.new( secret.encode(), signed_content, hashlib.sha256 ).hexdigest() - return hmac.compare_digest(v2_sig, expected_v2) + return _hmac_str_equal(v2_sig, expected_v2) # Generic V1 (legacy): X-Webhook-Signature = # (deprecated — no replay protection, since the signature only @@ -999,7 +1013,7 @@ def _header(name: str) -> str: "'.').", route_name, ) - return hmac.compare_digest(generic_sig, expected) + return _hmac_str_equal(generic_sig, expected) # No recognised signature header but secret is configured → reject logger.debug( @@ -1053,7 +1067,7 @@ def _validate_svix_signature( version, signature = part.split(",", 1) except ValueError: continue - if version == "v1" and hmac.compare_digest(signature, expected): + if version == "v1" and _hmac_str_equal(signature, expected): return True return False diff --git a/scripts/release.py b/scripts/release.py index 476fa82001ac..f6107f1f722a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "drexux0@gmail.com": "Drexuxux", # webhook: HMAC signature comparison hardened against non-ASCII crash "Burgunthy@users.noreply.github.com": "Burgunthy", # PR #20096 salvage (gateway: profile-based routing for inbound messages) "75556242+webtecnica@users.noreply.github.com": "webtecnica", # PR #63360 salvage (nous: restore inference-api base_url) "skosarevivan@yandex.ru": "Epoxidex", # PR #29820 salvage (ollama: top-level reasoning_effort=none; #25758) diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 40d15ecd49fc..2533ced0fd97 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -165,6 +165,40 @@ def test_validate_no_signature_with_secret_rejects(self): req = _mock_request(headers={}) # no sig headers at all assert adapter._validate_signature(req, b"{}", "my-secret") is False + def test_non_ascii_signature_headers_reject_without_raising(self): + """The signature headers are attacker-controlled on a public, unauth + endpoint. A non-ASCII byte in one must be rejected (False), not crash + the handler: hmac.compare_digest raises TypeError on a non-ASCII str.""" + adapter = _make_adapter() + body = b'{"action": "opened"}' + secret = "webhook-secret-42" + hostile = "ské-not-a-valid-signature" + for header in ( + "X-Hub-Signature-256", + "X-Gitlab-Token", + "X-Webhook-Signature", + ): + req = _mock_request(headers={header: hostile}) + # Must return False, never raise. + assert adapter._validate_signature(req, body, secret) is False + + def test_non_ascii_generic_v2_signature_rejected(self): + """V2 branch (timestamp-bound) also rejects a non-ASCII signature.""" + adapter = _make_adapter() + req = _mock_request(headers={ + "X-Webhook-Signature-V2": "ské-bad", + "X-Webhook-Timestamp": str(int(time.time())), + }) + assert adapter._validate_signature(req, b"{}", "secret") is False + + def test_non_ascii_secret_still_validates_a_matching_token(self): + """A non-ASCII configured secret must still match its exact GitLab + token value byte for byte (bytes comparison keeps this working).""" + adapter = _make_adapter() + secret = "gl-tökén-välue" + req = _mock_request(headers={"X-Gitlab-Token": secret}) + assert adapter._validate_signature(req, b"{}", secret) is True + def test_validate_no_secret_allows_all(self): """When the secret is empty/falsy, the validator is never even called by the handler (secret check is 'if secret and secret != _INSECURE...'). From c3f19cfef3b69f8a912895f79b91615c94d0dbb2 Mon Sep 17 00:00:00 2001 From: Drexuxux Date: Thu, 16 Jul 2026 15:14:48 +0300 Subject: [PATCH 2/2] test(webhook): cover the Svix v1 branch in the non-ASCII signature regression The fix routes the Svix v1 comparison through _hmac_str_equal too, but the existing non-ASCII tests only exercised the GitHub/GitLab/generic V1/V2 branches. Add a Svix case (valid svix-id + fresh svix-timestamp so it reaches the v1, compare) with a non-ASCII signature, which raised TypeError before the fix and now rejects cleanly. --- tests/gateway/test_webhook_adapter.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 2533ced0fd97..0dcb82eb8518 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -191,6 +191,18 @@ def test_non_ascii_generic_v2_signature_rejected(self): }) assert adapter._validate_signature(req, b"{}", "secret") is False + def test_non_ascii_svix_signature_rejected(self): + """The Svix branch also runs its `v1,` comparison through the + hardened helper: a valid svix-id + fresh timestamp reaches the compare, + and a non-ASCII signature must reject rather than raise.""" + adapter = _make_adapter() + req = _mock_request(headers={ + "svix-id": "msg_2xabc", + "svix-timestamp": str(int(time.time())), # inside the replay window + "svix-signature": "v1,ské-not-a-valid-base64-sig", + }) + assert adapter._validate_signature(req, b'{"x":1}', "shh-secret") is False + def test_non_ascii_secret_still_validates_a_matching_token(self): """A non-ASCII configured secret must still match its exact GitLab token value byte for byte (bytes comparison keeps this working)."""