Skip to content

Commit e3465a1

Browse files
authored
Merge pull request #648 from orbcorp/dmeadows/fix-webhook-timestamp-handling
fix(client): use UTC timezone as default if webhook timestamp header omits it
2 parents 073bfcb + 02b8c8d commit e3465a1

File tree

2 files changed

+103
-5
lines changed

2 files changed

+103
-5
lines changed

src/orb/resources/webhooks.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ def verify_signature(
5656
now = datetime.now(tz=timezone.utc)
5757

5858
try:
59-
timestamp = datetime.fromisoformat(msg_timestamp).astimezone()
59+
timestamp = datetime.fromisoformat(msg_timestamp)
60+
# If the timestamp doesn't have timezone info, assume it's UTC
61+
if timestamp.tzinfo is None:
62+
timestamp = timestamp.replace(tzinfo=timezone.utc)
63+
timestamp = timestamp.astimezone()
6064
except Exception as err:
6165
raise ValueError("Invalid signature headers. Could not convert to timestamp") from err
6266

@@ -140,7 +144,11 @@ def verify_signature(
140144
now = datetime.now(tz=timezone.utc)
141145

142146
try:
143-
timestamp = datetime.fromisoformat(msg_timestamp).astimezone()
147+
timestamp = datetime.fromisoformat(msg_timestamp)
148+
# If the timestamp doesn't have timezone info, assume it's UTC
149+
if timestamp.tzinfo is None:
150+
timestamp = timestamp.replace(tzinfo=timezone.utc)
151+
timestamp = timestamp.astimezone()
144152
except Exception as err:
145153
raise ValueError("Invalid signature headers. Could not convert to timestamp") from err
146154

tests/api_resources/test_webhooks.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import os
66
from typing import Any, cast
7-
from datetime import datetime, timedelta
7+
from datetime import datetime, timezone, timedelta
88

99
import pytest
1010
import time_machine
@@ -18,7 +18,11 @@ class TestWebhooks:
1818
parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"])
1919

2020
timestamp = "2024-03-27T15:42:29.551"
21-
fake_now = datetime.fromisoformat(timestamp).astimezone()
21+
# Fix: Ensure fake_now matches how webhook timestamps are now parsed (UTC assumption)
22+
fake_now_dt = datetime.fromisoformat(timestamp)
23+
if fake_now_dt.tzinfo is None:
24+
fake_now_dt = fake_now_dt.replace(tzinfo=timezone.utc)
25+
fake_now = fake_now_dt.astimezone()
2226

2327
payload = """{"id": "o4mmewpfNNTnjfZc", "created_at": "2024-03-27T15:42:29+00:00", "type": "resource_event.test", "properties": {"message": "A test webhook from Orb. Happy testing!"}}"""
2428
signature = "9d25de966891ab0bc18754faf8d83d0980b44ae330fcc130b41a6cf3daf1f391"
@@ -106,12 +110,98 @@ def test_verify_signature(self, client: Orb) -> None:
106110
secret=secret,
107111
)
108112

113+
def test_microsecond_precision_issue_fixed(self, client: Orb) -> None:
114+
"""Test that the webhook timestamp parsing issue is fixed for the reported examples."""
115+
import hmac
116+
import hashlib
117+
118+
secret = self.secret
119+
120+
# Test cases from the reported issue - these should all work now
121+
test_cases = [
122+
("2025-08-08T21:35:11.531998+00:00", "2025-08-08T21:35:11.445"),
123+
("2025-08-08T21:32:02.585239+00:00", "2025-08-08T21:32:02.497"),
124+
("2025-08-08T21:35:42.810490+00:00", "2025-08-08T21:35:42.660"),
125+
]
126+
127+
for _, (system_time_str, webhook_timestamp) in enumerate(test_cases, 1):
128+
system_time = datetime.fromisoformat(system_time_str)
129+
130+
# Generate the correct signature
131+
to_sign = f"v1:{webhook_timestamp}:{self.payload}".encode("utf-8")
132+
signature = hmac.new(secret.encode("utf-8"), to_sign, hashlib.sha256).hexdigest()
133+
134+
with time_machine.travel(system_time):
135+
# This should now work without raising "Webhook timestamp is too new"
136+
client.webhooks.verify_signature(
137+
payload=self.payload,
138+
headers={"X-Orb-Timestamp": webhook_timestamp, "X-Orb-Signature": f"v1={signature}"},
139+
secret=secret,
140+
)
141+
142+
# Also test the unwrap method
143+
result = client.webhooks.unwrap(
144+
payload=self.payload,
145+
headers={"X-Orb-Timestamp": webhook_timestamp, "X-Orb-Signature": f"v1={signature}"},
146+
secret=secret,
147+
)
148+
assert result is not None
149+
150+
def test_timezone_aware_timestamps_still_work(self, client: Orb) -> None:
151+
"""Test that webhook timestamps with explicit timezone info still work."""
152+
import hmac
153+
import hashlib
154+
from datetime import timezone
155+
156+
secret = self.secret
157+
158+
# Test with explicit UTC timezone
159+
system_time = datetime(2025, 8, 8, 21, 35, 11, 531998, tzinfo=timezone.utc)
160+
webhook_timestamp = "2025-08-08T21:35:11.445+00:00" # Explicit UTC
161+
162+
to_sign = f"v1:{webhook_timestamp}:{self.payload}".encode("utf-8")
163+
signature = hmac.new(secret.encode("utf-8"), to_sign, hashlib.sha256).hexdigest()
164+
165+
with time_machine.travel(system_time):
166+
client.webhooks.verify_signature(
167+
payload=self.payload,
168+
headers={"X-Orb-Timestamp": webhook_timestamp, "X-Orb-Signature": f"v1={signature}"},
169+
secret=secret,
170+
)
171+
172+
def test_webhook_timestamp_actually_too_new(self, client: Orb) -> None:
173+
"""Test that webhooks that are genuinely too new are still rejected."""
174+
import hmac
175+
import hashlib
176+
from datetime import timezone
177+
178+
secret = self.secret
179+
180+
# Set system time to be much earlier than webhook timestamp (more than 5 minute tolerance)
181+
system_time = datetime(2025, 8, 8, 21, 30, 0, 0, tzinfo=timezone.utc)
182+
webhook_timestamp = "2025-08-08T21:36:00.000" # 6 minutes later - should be rejected
183+
184+
to_sign = f"v1:{webhook_timestamp}:{self.payload}".encode("utf-8")
185+
signature = hmac.new(secret.encode("utf-8"), to_sign, hashlib.sha256).hexdigest()
186+
187+
with time_machine.travel(system_time):
188+
with pytest.raises(ValueError, match="Webhook timestamp is too new"):
189+
client.webhooks.verify_signature(
190+
payload=self.payload,
191+
headers={"X-Orb-Timestamp": webhook_timestamp, "X-Orb-Signature": f"v1={signature}"},
192+
secret=secret,
193+
)
194+
109195

110196
class TestAsyncWebhooks:
111197
parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"])
112198

113199
timestamp = "2024-03-27T15:42:29.551"
114-
fake_now = datetime.fromisoformat(timestamp).astimezone()
200+
# Fix: Ensure fake_now matches how webhook timestamps are now parsed (UTC assumption)
201+
fake_now_dt = datetime.fromisoformat(timestamp)
202+
if fake_now_dt.tzinfo is None:
203+
fake_now_dt = fake_now_dt.replace(tzinfo=timezone.utc)
204+
fake_now = fake_now_dt.astimezone()
115205

116206
payload = """{"id": "o4mmewpfNNTnjfZc", "created_at": "2024-03-27T15:42:29+00:00", "type": "resource_event.test", "properties": {"message": "A test webhook from Orb. Happy testing!"}}"""
117207
signature = "9d25de966891ab0bc18754faf8d83d0980b44ae330fcc130b41a6cf3daf1f391"

0 commit comments

Comments
 (0)