"""Test that HMAC signature validation happens BEFORE rate limiting. This verifies the fix for bug #12544: invalid signature requests must NOT consume rate-limit quota. Before the fix, rate limiting was applied before signature validation, so an attacker could exhaust a victim's rate limit with invalidly-signed requests and then make valid requests that get rejected with 429. The correct order is: 1. Read body 2. Validate HMAC signature (reject 401 if invalid) 3. Rate limit check (reject 429 if over limit) 4. Process the webhook """ import hashlib import hmac import json import pytest from aiohttp import web from aiohttp.test_utils import TestClient, TestServer from gateway.platforms.webhook import WebhookAdapter from gateway.config import PlatformConfig def _make_adapter(routes, rate_limit=5, **extra_kw) -> WebhookAdapter: """Create a WebhookAdapter with the given routes.""" extra = { "host": "0.0.0.0", "port": 0, "routes": routes, "rate_limit": rate_limit, } extra.update(extra_kw) config = PlatformConfig(enabled=True, extra=extra) return WebhookAdapter(config) def _create_app(adapter: WebhookAdapter) -> web.Application: """Build the aiohttp Application from the adapter.""" app = web.Application() app.router.add_get("/health", adapter._handle_health) app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook) return app def _github_signature(body: bytes, secret: str) -> str: """Compute X-Hub-Signature-256 for *body* using *secret*.""" return "sha256=" + hmac.new( secret.encode(), body, hashlib.sha256 ).hexdigest() SIMPLE_PAYLOAD = {"event": "test", "data": "hello"} class TestSignatureBeforeRateLimit: """Verify that invalid signatures do NOT consume rate limit quota.""" @pytest.mark.asyncio async def test_invalid_signature_does_not_consume_rate_limit(self): """Send requests with invalid signatures up to the rate limit, then send a valid-signed request and verify it succeeds. BEFORE FIX: Invalid signatures consume the rate limit bucket, so after 'rate_limit' bad requests the valid one would get 429. AFTER FIX: Invalid signatures are rejected with 401 first (before rate limiting), so the rate limit bucket is untouched. The valid request after many bad ones still succeeds. """ secret = "test-secret-key" route_name = "test-route" routes = { route_name: { "secret": secret, "events": ["push"], "prompt": "Event: {event}", "deliver": "log", } } rate_limit = 5 adapter = _make_adapter(routes, rate_limit=rate_limit) captured_events = [] async def _capture(event): captured_events.append(event) adapter.handle_message = _capture app = _create_app(adapter) body = json.dumps(SIMPLE_PAYLOAD).encode() async with TestClient(TestServer(app)) as cli: # First exhaust the rate limit with invalid signatures for i in range(rate_limit): resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": "sha256=invalid", # bad sig "X-GitHub-Delivery": f"bad-{i}", }, ) # Each invalid signature should be rejected with 401 assert resp.status == 401, ( f"Expected 401 for invalid signature, got {resp.status}" ) # Now send a valid-signed request — it MUST succeed (202) # BEFORE FIX: This would return 429 because the 5 bad requests # consumed the rate limit bucket. # AFTER FIX: Bad requests don't touch rate limiting, so valid # request succeeds. valid_sig = _github_signature(body, secret) resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": valid_sig, "X-GitHub-Delivery": "good-001", }, ) assert resp.status == 202, ( f"Expected 202 for valid request after invalid signatures, " f"got {resp.status}. Rate limit may have been consumed by " f"invalid requests (bug #12544 not fixed)." ) data = await resp.json() assert data["status"] == "accepted" # The valid event should have been captured assert len(captured_events) == 1 @pytest.mark.asyncio async def test_valid_signature_still_rate_limited(self): """Verify that VALID requests still respect rate limiting normally.""" secret = "test-secret-key" route_name = "test-route" routes = { route_name: { "secret": secret, "events": ["push"], "prompt": "Event: {event}", "deliver": "log", } } rate_limit = 3 adapter = _make_adapter(routes, rate_limit=rate_limit) captured_events = [] async def _capture(event): captured_events.append(event) adapter.handle_message = _capture app = _create_app(adapter) body = json.dumps(SIMPLE_PAYLOAD).encode() async with TestClient(TestServer(app)) as cli: # Send 'rate_limit' valid requests — all should succeed for i in range(rate_limit): valid_sig = _github_signature(body, secret) resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": valid_sig, "X-GitHub-Delivery": f"good-{i}", }, ) assert resp.status == 202 # The next valid request SHOULD be rate-limited valid_sig = _github_signature(body, secret) resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": valid_sig, "X-GitHub-Delivery": "good-over-limit", }, ) assert resp.status == 429, ( f"Expected 429 when exceeding rate limit with valid requests, " f"got {resp.status}" ) @pytest.mark.asyncio async def test_mixed_valid_and_invalid_signatures(self): """Interleave invalid and valid requests. Only valid ones count against the rate limit.""" secret = "test-secret-key" route_name = "test-route" routes = { route_name: { "secret": secret, "events": ["push"], "prompt": "Event: {event}", "deliver": "log", } } rate_limit = 3 adapter = _make_adapter(routes, rate_limit=rate_limit) captured_events = [] async def _capture(event): captured_events.append(event) adapter.handle_message = _capture app = _create_app(adapter) body = json.dumps(SIMPLE_PAYLOAD).encode() async with TestClient(TestServer(app)) as cli: # Send 2 valid requests (should succeed) for i in range(2): valid_sig = _github_signature(body, secret) resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": valid_sig, "X-GitHub-Delivery": f"good-{i}", }, ) assert resp.status == 202 # Send 10 invalid requests (should all get 401, not consume quota) for i in range(10): resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": "sha256=invalid", "X-GitHub-Delivery": f"bad-{i}", }, ) assert resp.status == 401 # One more valid request should STILL succeed (only 2 consumed) valid_sig = _github_signature(body, secret) resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": valid_sig, "X-GitHub-Delivery": "good-3", }, ) assert resp.status == 202, ( f"Expected 202 for 3rd valid request after many invalid ones, " f"got {resp.status}" ) # The 4th valid request should be rate-limited (2 + 2 = 4 = limit) valid_sig = _github_signature(body, secret) resp = await cli.post( f"/webhooks/{route_name}", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "push", "X-Hub-Signature-256": valid_sig, "X-GitHub-Delivery": "good-4", }, ) assert resp.status == 429 assert len(captured_events) == 3