Skip to content

Commit f005518

Browse files
committed
fix: bind webhook HMAC signature to timestamp to prevent replay
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
1 parent 070a2b9 commit f005518

2 files changed

Lines changed: 71 additions & 7 deletions

File tree

‎plugins/webhook_notification/webhook_notification.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import hashlib
1919
import hmac
2020
import logging
21+
import time
2122
from typing import Any, Dict, List, Optional
2223

2324
# Third-Party
@@ -82,6 +83,7 @@ class AuthenticationConfig(BaseModel):
8283
hmac_secret: Optional[str] = None
8384
hmac_algorithm: str = "sha256"
8485
hmac_header: str = "X-Signature"
86+
hmac_timestamp_header: str = "X-Timestamp"
8587

8688

8789
class WebhookConfig(BaseModel):
@@ -165,19 +167,24 @@ async def _render_template(self, template: str, context: Dict[str, Any]) -> str:
165167
result = result.replace(placeholder, str(value))
166168
return result
167169

168-
def _create_hmac_signature(self, payload: str, secret: str, algorithm: str) -> str:
169-
"""Create HMAC signature for the payload.
170+
def _create_hmac_signature(self, payload: str, secret: str, algorithm: str, timestamp: str) -> str:
171+
"""Create HMAC signature for the payload, bound to a timestamp.
172+
173+
The signed material is ``{timestamp}.{payload}`` so a captured
174+
signature cannot be replayed at a different time.
170175
171176
Args:
172177
payload: The payload to sign.
173178
secret: The secret key for HMAC.
174179
algorithm: The hash algorithm to use.
180+
timestamp: Unix epoch seconds as a string, included in the signed material.
175181
176182
Returns:
177-
str: The HMAC signature string.
183+
str: The HMAC signature string in the form ``{algorithm}={hex}``.
178184
"""
179185
hash_func = getattr(hashlib, algorithm, hashlib.sha256)
180-
signature = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hash_func).hexdigest()
186+
signed_material = f"{timestamp}.{payload}".encode("utf-8")
187+
signature = hmac.new(secret.encode("utf-8"), signed_material, hash_func).hexdigest()
181188
return f"{algorithm}={signature}"
182189

183190
async def _send_webhook(
@@ -241,8 +248,10 @@ async def _send_webhook(
241248
elif auth_config.type == AuthenticationType.API_KEY and auth_config.api_key:
242249
headers[auth_config.api_key_header] = auth_config.api_key
243250
elif auth_config.type == AuthenticationType.HMAC and auth_config.hmac_secret:
244-
signature = self._create_hmac_signature(payload_json, auth_config.hmac_secret, auth_config.hmac_algorithm)
251+
timestamp = str(int(time.time()))
252+
signature = self._create_hmac_signature(payload_json, auth_config.hmac_secret, auth_config.hmac_algorithm, timestamp)
245253
headers[auth_config.hmac_header] = signature
254+
headers[auth_config.hmac_timestamp_header] = timestamp
246255

247256
# Attempt delivery with retry logic
248257
for attempt in range(webhook.retry_attempts + 1):

‎tests/unit/mcpgateway/plugins/plugins/webhook_notification/test_webhook_notification.py‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import json
10+
import time
1011
from unittest.mock import AsyncMock, MagicMock, patch
1112

1213
import pytest
@@ -92,18 +93,72 @@ async def test_template_rendering(self):
9293

9394
@pytest.mark.asyncio
9495
async def test_hmac_signature_creation(self):
95-
"""Test HMAC signature creation."""
96+
"""Test HMAC signature creation includes timestamp in signed material."""
9697
plugin = _create_plugin()
9798

9899
payload = '{"test": "data"}'
99100
secret = "test-secret" # pragma: allowlist secret
100101
algorithm = "sha256"
102+
timestamp = "1700000000"
101103

102-
signature = plugin._create_hmac_signature(payload, secret, algorithm)
104+
signature = plugin._create_hmac_signature(payload, secret, algorithm, timestamp)
103105

104106
assert signature.startswith("sha256=")
105107
assert len(signature) > 10 # Basic length check
106108

109+
# A different timestamp must produce a different signature (timestamp is in the signed material).
110+
signature_other_ts = plugin._create_hmac_signature(payload, secret, algorithm, "1700000001")
111+
assert signature != signature_other_ts
112+
113+
@pytest.mark.asyncio
114+
@patch("plugins.webhook_notification.webhook_notification.httpx.AsyncClient")
115+
async def test_webhook_delivery_with_hmac_auth_sends_timestamp_header(self, mock_client_class):
116+
"""Test HMAC auth sends both signature and timestamp headers."""
117+
mock_client = AsyncMock()
118+
mock_response = MagicMock()
119+
mock_response.status_code = 200
120+
mock_client.post.return_value = mock_response
121+
mock_client_class.return_value = mock_client
122+
123+
config = {
124+
"webhooks": [
125+
{
126+
"url": "https://hooks.example.com/webhook",
127+
"events": ["tool_success"],
128+
"authentication": {
129+
"type": "hmac",
130+
"hmac_secret": "test-secret", # pragma: allowlist secret
131+
"hmac_algorithm": "sha256",
132+
"hmac_header": "X-Signature",
133+
"hmac_timestamp_header": "X-Timestamp",
134+
},
135+
"retry_attempts": 0,
136+
"enabled": True,
137+
}
138+
]
139+
}
140+
141+
plugin = _create_plugin(config)
142+
plugin._client = mock_client
143+
144+
before = int(time.time())
145+
context = _create_context()
146+
webhook = plugin._cfg.webhooks[0]
147+
148+
await plugin._send_webhook(webhook=webhook, event=EventType.TOOL_SUCCESS, context=context)
149+
150+
after = int(time.time())
151+
152+
call_args = mock_client.post.call_args
153+
headers = call_args[1]["headers"]
154+
155+
assert "X-Signature" in headers
156+
assert headers["X-Signature"].startswith("sha256=")
157+
158+
assert "X-Timestamp" in headers
159+
ts = int(headers["X-Timestamp"])
160+
assert before <= ts <= after
161+
107162
@pytest.mark.asyncio
108163
@patch("plugins.webhook_notification.webhook_notification.httpx.AsyncClient")
109164
async def test_webhook_delivery_success(self, mock_client_class):

0 commit comments

Comments
 (0)