hermes-agent/tests/gateway/test_webhook_signature_rate_limit.py

289 lines
10 KiB
Python

"""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