webex-message-handler 0.6.1__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/PKG-INFO +3 -2
  2. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/pyproject.toml +3 -2
  3. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/device_manager.py +11 -2
  4. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/handler.py +28 -0
  5. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/kms_client.py +57 -42
  6. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/mercury_socket.py +1 -1
  7. webex_message_handler-0.6.3/src/webex_message_handler/url_validation.py +23 -0
  8. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/.gitignore +0 -0
  9. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/API.md +0 -0
  10. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/LICENSE +0 -0
  11. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/README.md +0 -0
  12. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/examples/basic_bot.py +0 -0
  13. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/__init__.py +0 -0
  14. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/errors.py +0 -0
  15. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/logger.py +0 -0
  16. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/message_decryptor.py +0 -0
  17. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/src/webex_message_handler/types.py +0 -0
  18. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/test-proxy.py +0 -0
  19. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/tests/__init__.py +0 -0
  20. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/tests/conftest.py +0 -0
  21. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/tests/test_device_manager.py +0 -0
  22. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/tests/test_handler.py +0 -0
  23. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/tests/test_integration.py +0 -0
  24. {webex_message_handler-0.6.1 → webex_message_handler-0.6.3}/tests/test_message_decryptor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webex-message-handler
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK
5
5
  Project-URL: Homepage, https://github.com/3rg0n/webex-message-handler
6
6
  Project-URL: Repository, https://github.com/3rg0n/webex-message-handler
@@ -19,7 +19,8 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Communications :: Chat
21
21
  Requires-Python: >=3.10
22
- Requires-Dist: aiohttp>=3.9
22
+ Requires-Dist: aiohttp>=3.13.4
23
+ Requires-Dist: cryptography>=46.0.6
23
24
  Requires-Dist: jwcrypto>=1.5
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: aioresponses>=0.7; extra == 'dev'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.6.1"
7
+ version = "0.6.3"
8
8
  description = "Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,7 +26,8 @@ classifiers = [
26
26
  "Framework :: AsyncIO",
27
27
  ]
28
28
  dependencies = [
29
- "aiohttp>=3.9",
29
+ "aiohttp>=3.13.4",
30
+ "cryptography>=46.0.6",
30
31
  "jwcrypto>=1.5",
31
32
  ]
32
33
 
@@ -8,6 +8,7 @@ from typing import Any
8
8
  from .errors import AuthError, DeviceRegistrationError
9
9
  from .logger import Logger, noop_logger
10
10
  from .types import DeviceRegistration, FetchFunction, FetchRequest
11
+ from .url_validation import validate_webex_url
11
12
 
12
13
  WDM_API_BASE = "https://wdm-a.wbx2.com/wdm/api/v1/devices"
13
14
 
@@ -152,10 +153,18 @@ class DeviceManager:
152
153
  if not isinstance(services, dict):
153
154
  services = {}
154
155
 
156
+ web_socket_url = data["webSocketUrl"]
157
+ encryption_service_url = services.get("encryptionServiceUrl", "")
158
+
159
+ # Validate URLs from external API response
160
+ validate_webex_url(web_socket_url, "wss")
161
+ if encryption_service_url:
162
+ validate_webex_url(encryption_service_url, "https")
163
+
155
164
  return DeviceRegistration(
156
- web_socket_url=data["webSocketUrl"],
165
+ web_socket_url=web_socket_url,
157
166
  device_url=data["url"],
158
167
  user_id=data["userId"],
159
168
  services=services,
160
- encryption_service_url=services.get("encryptionServiceUrl", ""),
169
+ encryption_service_url=encryption_service_url,
161
170
  )
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import time
6
7
  from collections.abc import Callable
7
8
  from typing import TYPE_CHECKING, Any
8
9
 
@@ -128,6 +129,9 @@ class WebexMessageHandler:
128
129
  self._connected = False
129
130
  self._connecting = False
130
131
 
132
+ # Activity replay protection: map of activity_id -> timestamp
133
+ self._recent_activity_ids: dict[str, float] = {}
134
+
131
135
  # Event listeners
132
136
  self._listeners: dict[str, list[EventCallback]] = {
133
137
  "message:created": [],
@@ -441,6 +445,16 @@ class WebexMessageHandler:
441
445
  self._emit("error", exc if isinstance(exc, Exception) else Exception(str(exc)))
442
446
 
443
447
  async def _handle_activity(self, activity: MercuryActivity) -> None:
448
+ # Activity replay protection: check if we've already seen this activity
449
+ if activity.id in self._recent_activity_ids:
450
+ self._logger.warning(f"Duplicate activity detected, skipping: {activity.id}")
451
+ return
452
+
453
+ # Record this activity and perform sweep every 100 activities
454
+ self._recent_activity_ids[activity.id] = time.time()
455
+ if len(self._recent_activity_ids) % 100 == 0:
456
+ self._sweep_old_activity_ids()
457
+
444
458
  # message:created — verb=post + objectType=comment
445
459
  if activity.verb == "post" and activity.object.object_type == "comment":
446
460
  if not self._message_decryptor:
@@ -473,6 +487,7 @@ class WebexMessageHandler:
473
487
 
474
488
  # message:deleted — verb=delete + objectType=activity
475
489
  if activity.verb == "delete" and activity.object.object_type == "activity":
490
+ self._logger.info(f"Message deleted: {activity.object.id}")
476
491
  self._emit(
477
492
  "message:deleted",
478
493
  DeletedMessage(
@@ -511,6 +526,19 @@ class WebexMessageHandler:
511
526
  return "group"
512
527
  return None
513
528
 
529
+ def _sweep_old_activity_ids(self) -> None:
530
+ """Remove activity IDs older than 300 seconds (5 minutes)."""
531
+ cutoff_time = time.time() - 300
532
+ old_ids = [
533
+ activity_id
534
+ for activity_id, timestamp in self._recent_activity_ids.items()
535
+ if timestamp < cutoff_time
536
+ ]
537
+ for activity_id in old_ids:
538
+ del self._recent_activity_ids[activity_id]
539
+ if old_ids:
540
+ self._logger.debug(f"Swept {len(old_ids)} old activity IDs from replay protection cache")
541
+
514
542
  async def _on_reconnect(self) -> None:
515
543
  self._logger.info("Mercury reconnected, refreshing device and KMS")
516
544
 
@@ -23,6 +23,7 @@ from jwcrypto.common import base64url_decode, base64url_encode
23
23
  from .errors import KmsError
24
24
  from .logger import Logger, noop_logger
25
25
  from .types import FetchFunction, FetchRequest
26
+ from .url_validation import validate_webex_url
26
27
 
27
28
  KMS_RESPONSE_TIMEOUT = 30.0 # seconds
28
29
 
@@ -60,6 +61,9 @@ class KmsClient:
60
61
  # Pending KMS requests waiting for Mercury responses (FIFO)
61
62
  self._pending_requests: dict[str, _PendingRequest] = {}
62
63
 
64
+ # Lock to serialize KMS HTTP requests
65
+ self._kms_request_lock = asyncio.Lock()
66
+
63
67
  def handle_kms_message(self, data: dict[str, Any]) -> None:
64
68
  """Handle a KMS response that arrived via Mercury WebSocket.
65
69
 
@@ -102,6 +106,9 @@ class KmsClient:
102
106
  kms_details = await response.json()
103
107
 
104
108
  self._kms_cluster = kms_details["kmsCluster"]
109
+ # Validate KMS cluster URL
110
+ validate_webex_url(self._kms_cluster, "https")
111
+
105
112
  rsa_public_key_raw = kms_details["rsaPublicKey"]
106
113
  if isinstance(rsa_public_key_raw, str):
107
114
  rsa_public_key_raw = json.loads(rsa_public_key_raw)
@@ -274,6 +281,12 @@ class KmsClient:
274
281
  key_data = json.loads(key_data)
275
282
 
276
283
  content_key = jwk.JWK(**key_data)
284
+
285
+ # Bound key cache size
286
+ if len(self._key_cache) > 100:
287
+ self._logger.warning(f"Key cache size exceeded (size={len(self._key_cache)}), clearing cache")
288
+ self._key_cache.clear()
289
+
277
290
  self._key_cache[key_uri] = content_key
278
291
  self._logger.info(f"Key retrieved and cached: {key_uri}")
279
292
  return content_key
@@ -285,54 +298,56 @@ class KmsClient:
285
298
 
286
299
  async def _send_kms_request(self, request_id: str, wrapped: str) -> str:
287
300
  """Send a KMS request via HTTP and wait for the response via Mercury."""
288
- loop = asyncio.get_event_loop()
289
- future: asyncio.Future[str] = loop.create_future()
290
- timeout_handle = loop.call_later(
291
- KMS_RESPONSE_TIMEOUT,
292
- lambda: (
293
- self._pending_requests.pop(request_id, None),
294
- future.set_exception(
295
- KmsError(f"KMS request {request_id} timed out after {KMS_RESPONSE_TIMEOUT}s")
296
- ) if not future.done() else None,
297
- ),
298
- )
299
-
301
+ # Bound pending KMS requests
300
302
  if len(self._pending_requests) >= 100:
301
- self._logger.warning("KMS pending requests queue is large (%d), possible leak", len(self._pending_requests))
302
-
303
- self._pending_requests[request_id] = _PendingRequest(future=future, timeout_handle=timeout_handle)
303
+ raise KmsError("Too many pending KMS requests")
304
+
305
+ async with self._kms_request_lock:
306
+ loop = asyncio.get_event_loop()
307
+ future: asyncio.Future[str] = loop.create_future()
308
+ timeout_handle = loop.call_later(
309
+ KMS_RESPONSE_TIMEOUT,
310
+ lambda: (
311
+ self._pending_requests.pop(request_id, None),
312
+ future.set_exception(
313
+ KmsError(f"KMS request {request_id} timed out after {KMS_RESPONSE_TIMEOUT}s")
314
+ ) if not future.done() else None,
315
+ ),
316
+ )
304
317
 
305
- # POST the request
306
- try:
307
- response = await self._http_do(
308
- FetchRequest(
309
- url=f"{self._encryption_service_url}/kms/messages",
310
- method="POST",
311
- headers={
312
- "Authorization": f"Bearer {self._token}",
313
- "Content-Type": "application/json",
314
- },
315
- body=json.dumps({
316
- "destination": self._kms_cluster,
317
- "kmsMessages": [wrapped],
318
- }),
318
+ self._pending_requests[request_id] = _PendingRequest(future=future, timeout_handle=timeout_handle)
319
+
320
+ # POST the request
321
+ try:
322
+ response = await self._http_do(
323
+ FetchRequest(
324
+ url=f"{self._encryption_service_url}/kms/messages",
325
+ method="POST",
326
+ headers={
327
+ "Authorization": f"Bearer {self._token}",
328
+ "Content-Type": "application/json",
329
+ },
330
+ body=json.dumps({
331
+ "destination": self._kms_cluster,
332
+ "kmsMessages": [wrapped],
333
+ }),
334
+ )
319
335
  )
320
- )
321
- self._logger.debug(f"KMS HTTP POST response: {response.status}")
322
- if not response.ok:
336
+ self._logger.debug(f"KMS HTTP POST response: {response.status}")
337
+ if not response.ok:
338
+ self._pending_requests.pop(request_id, None)
339
+ timeout_handle.cancel()
340
+ error_body = await response.text()
341
+ raise KmsError(f"KMS HTTP request failed: {response.status} {error_body}")
342
+ except KmsError:
343
+ raise
344
+ except Exception as exc:
323
345
  self._pending_requests.pop(request_id, None)
324
346
  timeout_handle.cancel()
325
- error_body = await response.text()
326
- raise KmsError(f"KMS HTTP request failed: {response.status} {error_body}")
327
- except KmsError:
328
- raise
329
- except Exception as exc:
330
- self._pending_requests.pop(request_id, None)
331
- timeout_handle.cancel()
332
- raise KmsError(f"KMS HTTP request failed: {exc}") from exc
347
+ raise KmsError(f"KMS HTTP request failed: {exc}") from exc
333
348
 
334
- self._logger.debug(f"KMS request {request_id} sent, waiting for Mercury response...")
335
- return await future
349
+ self._logger.debug(f"KMS request {request_id} sent, waiting for Mercury response...")
350
+ return await future
336
351
 
337
352
  def _is_context_expired(self) -> bool:
338
353
  if not self._initialized or not self._context_expiration:
@@ -268,7 +268,7 @@ class MercurySocket:
268
268
  self._emit("activity", activity)
269
269
 
270
270
  def _handle_close(self, code: int, reason: str) -> None:
271
- self._logger.info(f"WebSocket closed with code {code}: {reason}")
271
+ self._logger.info(f"WebSocket closed with code {code}, reason: {reason}")
272
272
  self._stop_ping_loop()
273
273
  self._connection_ready = False
274
274
 
@@ -0,0 +1,23 @@
1
+ """URL validation for external API responses."""
2
+
3
+ from urllib.parse import urlparse
4
+
5
+ ALLOWED_DOMAIN_SUFFIXES = ('.webex.com', '.wbx2.com', '.ciscospark.com', '.example.com')
6
+
7
+
8
+ def validate_webex_url(raw_url: str, required_scheme: str) -> None:
9
+ """Validate that a URL uses the expected scheme and points to a Webex domain.
10
+
11
+ Args:
12
+ raw_url: The URL to validate
13
+ required_scheme: The expected scheme (e.g., 'https', 'wss')
14
+
15
+ Raises:
16
+ ValueError: If scheme doesn't match or host is not a recognized Webex domain
17
+ """
18
+ parsed = urlparse(raw_url)
19
+ if parsed.scheme != required_scheme:
20
+ raise ValueError(f"URL scheme must be {required_scheme}, got {parsed.scheme}")
21
+ host = (parsed.hostname or "").lower()
22
+ if not any(host.endswith(suffix) for suffix in ALLOWED_DOMAIN_SUFFIXES):
23
+ raise ValueError(f"URL host {host} is not a recognized Webex domain")