Files
hermes-agent/tests/gateway/test_webhook_signature_rate_limit.py
T
Teknium 39975613b1 test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

143 lines
4.9 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