mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
fix: fix vobiz webhook signature validation
This commit is contained in:
parent
a725fda274
commit
285de92528
3 changed files with 168 additions and 84 deletions
|
|
@ -2,9 +2,13 @@
|
|||
Vobiz implementation of the TelephonyProvider interface.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -201,23 +205,20 @@ class VobizProvider(TelephonyProvider):
|
|||
url: str,
|
||||
params: Dict[str, Any],
|
||||
signature: str,
|
||||
timestamp: str = None,
|
||||
nonce: str = None,
|
||||
body: str = "",
|
||||
signature_version: str = "v3",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify Vobiz webhook signature for security.
|
||||
|
||||
Vobiz uses HMAC-SHA256 signature verification with timestamp validation:
|
||||
- Header: x-vobiz-signature (HMAC-SHA256 hash)
|
||||
- Header: x-vobiz-timestamp (timestamp for replay protection)
|
||||
- Signature = HMAC-SHA256(auth_token, timestamp + '.' + body)
|
||||
Vobiz signs the callback base URL (query parameters stripped) with
|
||||
the account auth token and a request nonce:
|
||||
- V2: base64(HMAC-SHA256(auth_token, baseURL + nonce))
|
||||
- V3: base64(HMAC-SHA256(auth_token, baseURL + "." + nonce))
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if not signature or not timestamp:
|
||||
logger.warning("Missing signature or timestamp headers for Vobiz webhook")
|
||||
if not signature or not nonce:
|
||||
logger.warning("Missing signature or nonce headers for Vobiz webhook")
|
||||
return False
|
||||
|
||||
if not self.auth_token:
|
||||
|
|
@ -226,37 +227,33 @@ class VobizProvider(TelephonyProvider):
|
|||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# 1. Timestamp validation (within 5 minutes)
|
||||
webhook_timestamp = int(timestamp)
|
||||
current_timestamp = int(datetime.now(timezone.utc).timestamp())
|
||||
time_diff = abs(current_timestamp - webhook_timestamp)
|
||||
|
||||
if time_diff > 300: # 5 minutes = 300 seconds
|
||||
logger.warning(f"Vobiz webhook timestamp too old: {time_diff}s > 300s")
|
||||
return False
|
||||
|
||||
# 2. Signature verification
|
||||
# Create expected signature: HMAC-SHA256(auth_token, timestamp + '.' + body)
|
||||
payload = f"{timestamp}.{body}"
|
||||
expected_signature = hmac.new(
|
||||
self.auth_token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# 3. Compare signatures (timing-safe comparison)
|
||||
is_valid = hmac.compare_digest(expected_signature, signature)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"Vobiz webhook signature mismatch. Expected: {expected_signature[:8]}..., Got: {signature[:8]}..."
|
||||
)
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying Vobiz webhook signature: {e}")
|
||||
version = signature_version.lower()
|
||||
if version not in {"v2", "v3"}:
|
||||
logger.warning(f"Unsupported Vobiz signature version: {signature_version}")
|
||||
return False
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
base_url = urlunparse(
|
||||
(parsed_url.scheme, parsed_url.netloc, parsed_url.path, "", "", "")
|
||||
)
|
||||
signed_payload = base_url + (f".{nonce}" if version == "v3" else nonce)
|
||||
expected_signature = base64.b64encode(
|
||||
hmac.new(
|
||||
self.auth_token.encode("utf-8"),
|
||||
signed_payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
).decode("ascii")
|
||||
|
||||
is_valid = hmac.compare_digest(expected_signature, signature)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"Vobiz webhook signature mismatch. Expected: {expected_signature[:8]}..., Got: {signature[:8]}..."
|
||||
)
|
||||
|
||||
return is_valid
|
||||
|
||||
async def get_webhook_response(
|
||||
self, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
) -> str:
|
||||
|
|
@ -472,20 +469,34 @@ class VobizProvider(TelephonyProvider):
|
|||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Vobiz webhook for security.
|
||||
Uses HMAC-SHA256 over ``timestamp + '.' + body`` with the auth_token.
|
||||
Uses Vobiz's documented V3/V2 HMAC-SHA256 callback signatures.
|
||||
"""
|
||||
signature = headers.get("x-vobiz-signature", "")
|
||||
timestamp = headers.get("x-vobiz-timestamp")
|
||||
if not signature:
|
||||
# FIXME: Vobiz is not sending the x-vobiz-signature. Temporarily
|
||||
# returning True
|
||||
normalized_headers = {key.lower(): value for key, value in headers.items()}
|
||||
|
||||
signature = normalized_headers.get(
|
||||
"x-vobiz-signature-v3"
|
||||
) or normalized_headers.get("x-vobiz-signature-ma-v3", "")
|
||||
nonce = normalized_headers.get("x-vobiz-signature-v3-nonce")
|
||||
signature_version = "v3"
|
||||
|
||||
if not signature:
|
||||
signature = normalized_headers.get(
|
||||
"x-vobiz-signature-v2"
|
||||
) or normalized_headers.get("x-vobiz-signature-ma-v2", "")
|
||||
nonce = normalized_headers.get("x-vobiz-signature-v2-nonce")
|
||||
signature_version = "v2"
|
||||
|
||||
if not signature:
|
||||
logger.warning("Inbound Vobiz webhook missing X-Vobiz-Signature-V3/V2")
|
||||
return False
|
||||
|
||||
# Vobiz always signs its webhooks; missing header means the
|
||||
# request didn't come from Vobiz (or was tampered with).
|
||||
logger.warning("Inbound Vobiz webhook missing X-Vobiz-Signature")
|
||||
return True
|
||||
return await self.verify_webhook_signature(
|
||||
url, webhook_data, signature, timestamp, body
|
||||
url,
|
||||
webhook_data,
|
||||
signature,
|
||||
nonce,
|
||||
body,
|
||||
signature_version=signature_version,
|
||||
)
|
||||
|
||||
async def configure_inbound(
|
||||
|
|
|
|||
|
|
@ -93,8 +93,17 @@ async def handle_vobiz_hangup_callback(
|
|||
f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Verify signature if provided
|
||||
if x_vobiz_signature:
|
||||
# Verify signature if Vobiz provided any supported signature header.
|
||||
has_vobiz_signature = any(
|
||||
header in all_headers
|
||||
for header in (
|
||||
"x-vobiz-signature-v3",
|
||||
"x-vobiz-signature-ma-v3",
|
||||
"x-vobiz-signature-v2",
|
||||
"x-vobiz-signature-ma-v2",
|
||||
)
|
||||
)
|
||||
if has_vobiz_signature:
|
||||
# We need the workflow run to get organization for provider credentials
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
|
|
@ -118,11 +127,10 @@ async def handle_vobiz_hangup_callback(
|
|||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
is_valid = await provider.verify_inbound_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
all_headers,
|
||||
raw_body,
|
||||
)
|
||||
|
||||
|
|
@ -215,8 +223,17 @@ async def handle_vobiz_ring_callback(
|
|||
f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Verify signature if provided
|
||||
if x_vobiz_signature:
|
||||
# Verify signature if Vobiz provided any supported signature header.
|
||||
has_vobiz_signature = any(
|
||||
header in all_headers
|
||||
for header in (
|
||||
"x-vobiz-signature-v3",
|
||||
"x-vobiz-signature-ma-v3",
|
||||
"x-vobiz-signature-v2",
|
||||
"x-vobiz-signature-ma-v2",
|
||||
)
|
||||
)
|
||||
if has_vobiz_signature:
|
||||
# We need the workflow run to get organization for provider credentials
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
|
|
@ -242,11 +259,10 @@ async def handle_vobiz_ring_callback(
|
|||
f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}"
|
||||
)
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
is_valid = await provider.verify_inbound_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
all_headers,
|
||||
raw_body,
|
||||
)
|
||||
|
||||
|
|
@ -348,15 +364,23 @@ async def handle_vobiz_hangup_callback_by_workflow(
|
|||
workflow_run, workflow.organization_id
|
||||
)
|
||||
|
||||
if x_vobiz_signature:
|
||||
has_vobiz_signature = any(
|
||||
header in all_headers
|
||||
for header in (
|
||||
"x-vobiz-signature-v3",
|
||||
"x-vobiz-signature-ma-v3",
|
||||
"x-vobiz-signature-v2",
|
||||
"x-vobiz-signature-ma-v2",
|
||||
)
|
||||
)
|
||||
if has_vobiz_signature:
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
is_valid = await provider.verify_inbound_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
all_headers,
|
||||
raw_body,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue