mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Compare commits
6 Commits
gateway-pl
...
twilio-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5825f2c7e0 | ||
|
|
1e0b4006ca | ||
|
|
40f5b70c6c | ||
|
|
e339d3bb87 | ||
|
|
ee81a53cb6 | ||
|
|
5eae976c40 |
@@ -10,6 +10,9 @@ Shares credentials with the optional telephony skill — same env vars:
|
|||||||
|
|
||||||
Gateway-specific env vars:
|
Gateway-specific env vars:
|
||||||
- SMS_WEBHOOK_PORT (default 8080)
|
- SMS_WEBHOOK_PORT (default 8080)
|
||||||
|
- SMS_WEBHOOK_HOST (default 0.0.0.0)
|
||||||
|
- SMS_WEBHOOK_URL (public URL for Twilio signature validation — required)
|
||||||
|
- SMS_INSECURE_NO_SIGNATURE (true to disable signature validation — dev only)
|
||||||
- SMS_ALLOWED_USERS (comma-separated E.164 phone numbers)
|
- SMS_ALLOWED_USERS (comma-separated E.164 phone numbers)
|
||||||
- SMS_ALLOW_ALL_USERS (true/false)
|
- SMS_ALLOW_ALL_USERS (true/false)
|
||||||
- SMS_HOME_CHANNEL (phone number for cron delivery)
|
- SMS_HOME_CHANNEL (phone number for cron delivery)
|
||||||
@@ -17,6 +20,8 @@ Gateway-specific env vars:
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -36,6 +41,7 @@ logger = logging.getLogger(__name__)
|
|||||||
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
|
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
|
||||||
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
|
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
|
||||||
DEFAULT_WEBHOOK_PORT = 8080
|
DEFAULT_WEBHOOK_PORT = 8080
|
||||||
|
DEFAULT_WEBHOOK_HOST = "0.0.0.0"
|
||||||
|
|
||||||
# E.164 phone number pattern for redaction
|
# E.164 phone number pattern for redaction
|
||||||
_PHONE_RE = re.compile(r"\+[1-9]\d{6,14}")
|
_PHONE_RE = re.compile(r"\+[1-9]\d{6,14}")
|
||||||
@@ -77,6 +83,8 @@ class SmsAdapter(BasePlatformAdapter):
|
|||||||
self._webhook_port: int = int(
|
self._webhook_port: int = int(
|
||||||
os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))
|
os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))
|
||||||
)
|
)
|
||||||
|
self._webhook_host: str = os.getenv("SMS_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST)
|
||||||
|
self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip()
|
||||||
self._runner = None
|
self._runner = None
|
||||||
self._http_session: Optional["aiohttp.ClientSession"] = None
|
self._http_session: Optional["aiohttp.ClientSession"] = None
|
||||||
|
|
||||||
@@ -98,13 +106,33 @@ class SmsAdapter(BasePlatformAdapter):
|
|||||||
logger.error("[sms] TWILIO_PHONE_NUMBER not set — cannot send replies")
|
logger.error("[sms] TWILIO_PHONE_NUMBER not set — cannot send replies")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true"
|
||||||
|
|
||||||
|
if not self._webhook_url and not insecure_no_sig:
|
||||||
|
logger.error(
|
||||||
|
"[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio "
|
||||||
|
"signature validation. Set it to the public URL configured in your "
|
||||||
|
"Twilio console (e.g. https://example.com/webhooks/twilio). "
|
||||||
|
"For local development without validation, set "
|
||||||
|
"SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production).",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if insecure_no_sig and not self._webhook_url:
|
||||||
|
logger.warning(
|
||||||
|
"[sms] SMS_INSECURE_NO_SIGNATURE=true — Twilio signature validation "
|
||||||
|
"is DISABLED. Any client that can reach port %d can inject messages. "
|
||||||
|
"Do NOT use this in production.",
|
||||||
|
self._webhook_port,
|
||||||
|
)
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_post("/webhooks/twilio", self._handle_webhook)
|
app.router.add_post("/webhooks/twilio", self._handle_webhook)
|
||||||
app.router.add_get("/health", lambda _: web.Response(text="ok"))
|
app.router.add_get("/health", lambda _: web.Response(text="ok"))
|
||||||
|
|
||||||
self._runner = web.AppRunner(app)
|
self._runner = web.AppRunner(app)
|
||||||
await self._runner.setup()
|
await self._runner.setup()
|
||||||
site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port)
|
site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port)
|
||||||
await site.start()
|
await site.start()
|
||||||
self._http_session = aiohttp.ClientSession(
|
self._http_session = aiohttp.ClientSession(
|
||||||
timeout=aiohttp.ClientTimeout(total=30),
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
@@ -112,7 +140,8 @@ class SmsAdapter(BasePlatformAdapter):
|
|||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[sms] Twilio webhook server listening on port %d, from: %s",
|
"[sms] Twilio webhook server listening on %s:%d, from: %s",
|
||||||
|
self._webhook_host,
|
||||||
self._webhook_port,
|
self._webhook_port,
|
||||||
_redact_phone(self._from_number),
|
_redact_phone(self._from_number),
|
||||||
)
|
)
|
||||||
@@ -203,6 +232,74 @@ class SmsAdapter(BasePlatformAdapter):
|
|||||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||||
return content.strip()
|
return content.strip()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Twilio signature validation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _validate_twilio_signature(
|
||||||
|
self, url: str, post_params: dict, signature: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Validate ``X-Twilio-Signature`` header (HMAC-SHA1, base64).
|
||||||
|
|
||||||
|
Tries both with and without the default port for the URL scheme,
|
||||||
|
since Twilio may sign with either variant.
|
||||||
|
|
||||||
|
Algorithm: https://www.twilio.com/docs/usage/security#validating-requests
|
||||||
|
"""
|
||||||
|
if self._check_signature(url, post_params, signature):
|
||||||
|
return True
|
||||||
|
|
||||||
|
variant = self._port_variant_url(url)
|
||||||
|
if variant and self._check_signature(variant, post_params, signature):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_signature(
|
||||||
|
self, url: str, post_params: dict, signature: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Compute and compare a single Twilio signature."""
|
||||||
|
data_to_sign = url
|
||||||
|
for key in sorted(post_params.keys()):
|
||||||
|
data_to_sign += key + post_params[key]
|
||||||
|
mac = hmac.new(
|
||||||
|
self._auth_token.encode("utf-8"),
|
||||||
|
data_to_sign.encode("utf-8"),
|
||||||
|
hashlib.sha1,
|
||||||
|
)
|
||||||
|
computed = base64.b64encode(mac.digest()).decode("utf-8")
|
||||||
|
return hmac.compare_digest(computed, signature)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _port_variant_url(url: str) -> str | None:
|
||||||
|
"""Return the URL with the default port toggled, or None.
|
||||||
|
|
||||||
|
Only toggles default ports (443 for https, 80 for http).
|
||||||
|
Non-standard ports are never modified.
|
||||||
|
"""
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
default_ports = {"https": 443, "http": 80}
|
||||||
|
default_port = default_ports.get(parsed.scheme)
|
||||||
|
if default_port is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if parsed.port == default_port:
|
||||||
|
# Has explicit default port → strip it
|
||||||
|
return urllib.parse.urlunparse(
|
||||||
|
(parsed.scheme, parsed.hostname, parsed.path,
|
||||||
|
parsed.params, parsed.query, parsed.fragment)
|
||||||
|
)
|
||||||
|
elif parsed.port is None:
|
||||||
|
# No port → add default
|
||||||
|
netloc = f"{parsed.hostname}:{default_port}"
|
||||||
|
return urllib.parse.urlunparse(
|
||||||
|
(parsed.scheme, netloc, parsed.path,
|
||||||
|
parsed.params, parsed.query, parsed.fragment)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Non-standard port — no variant
|
||||||
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Twilio webhook handler
|
# Twilio webhook handler
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -213,7 +310,7 @@ class SmsAdapter(BasePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
raw = await request.read()
|
raw = await request.read()
|
||||||
# Twilio sends form-encoded data, not JSON
|
# Twilio sends form-encoded data, not JSON
|
||||||
form = urllib.parse.parse_qs(raw.decode("utf-8"))
|
form = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("[sms] webhook parse error: %s", e)
|
logger.error("[sms] webhook parse error: %s", e)
|
||||||
return web.Response(
|
return web.Response(
|
||||||
@@ -222,6 +319,27 @@ class SmsAdapter(BasePlatformAdapter):
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate Twilio request signature when SMS_WEBHOOK_URL is configured
|
||||||
|
if self._webhook_url:
|
||||||
|
twilio_sig = request.headers.get("X-Twilio-Signature", "")
|
||||||
|
if not twilio_sig:
|
||||||
|
logger.warning("[sms] Rejected: missing X-Twilio-Signature header")
|
||||||
|
return web.Response(
|
||||||
|
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
content_type="application/xml",
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
flat_params = {k: v[0] for k, v in form.items() if v}
|
||||||
|
if not self._validate_twilio_signature(
|
||||||
|
self._webhook_url, flat_params, twilio_sig
|
||||||
|
):
|
||||||
|
logger.warning("[sms] Rejected: invalid Twilio signature")
|
||||||
|
return web.Response(
|
||||||
|
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
content_type="application/xml",
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
|
||||||
# Extract fields (parse_qs returns lists)
|
# Extract fields (parse_qs returns lists)
|
||||||
from_number = (form.get("From", [""]))[0].strip()
|
from_number = (form.get("From", [""]))[0].strip()
|
||||||
to_number = (form.get("To", [""]))[0].strip()
|
to_number = (form.get("To", [""]))[0].strip()
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Tests for SMS (Twilio) platform integration.
|
"""Tests for SMS (Twilio) platform integration.
|
||||||
|
|
||||||
Covers config loading, format/truncate, echo prevention,
|
Covers config loading, format/truncate, echo prevention,
|
||||||
requirements check, and toolset verification.
|
requirements check, toolset verification, and Twilio signature validation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import os
|
import os
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -213,3 +216,321 @@ class TestSmsToolset:
|
|||||||
from tools.cronjob_tools import CRONJOB_SCHEMA
|
from tools.cronjob_tools import CRONJOB_SCHEMA
|
||||||
deliver_desc = CRONJOB_SCHEMA["parameters"]["properties"]["deliver"]["description"]
|
deliver_desc = CRONJOB_SCHEMA["parameters"]["properties"]["deliver"]["description"]
|
||||||
assert "sms" in deliver_desc.lower()
|
assert "sms" in deliver_desc.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Webhook host configuration ─────────────────────────────────────
|
||||||
|
|
||||||
|
class TestWebhookHostConfig:
|
||||||
|
"""Verify SMS_WEBHOOK_HOST env var and default."""
|
||||||
|
|
||||||
|
def test_default_host_is_all_interfaces(self):
|
||||||
|
from gateway.platforms.sms import DEFAULT_WEBHOOK_HOST
|
||||||
|
assert DEFAULT_WEBHOOK_HOST == "0.0.0.0"
|
||||||
|
|
||||||
|
def test_host_from_env(self):
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "tok",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
"SMS_WEBHOOK_HOST": "127.0.0.1",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
||||||
|
adapter = SmsAdapter(pc)
|
||||||
|
assert adapter._webhook_host == "127.0.0.1"
|
||||||
|
|
||||||
|
def test_webhook_url_from_env(self):
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "tok",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
"SMS_WEBHOOK_URL": "https://example.com/webhooks/twilio",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
||||||
|
adapter = SmsAdapter(pc)
|
||||||
|
assert adapter._webhook_url == "https://example.com/webhooks/twilio"
|
||||||
|
|
||||||
|
def test_webhook_url_stripped(self):
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "tok",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
"SMS_WEBHOOK_URL": " https://example.com/webhooks/twilio ",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
||||||
|
adapter = SmsAdapter(pc)
|
||||||
|
assert adapter._webhook_url == "https://example.com/webhooks/twilio"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Startup guard (fail-closed) ────────────────────────────────────
|
||||||
|
|
||||||
|
class TestStartupGuard:
|
||||||
|
"""Adapter must refuse to start without SMS_WEBHOOK_URL."""
|
||||||
|
|
||||||
|
def _make_adapter(self, extra_env=None):
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "tok",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
}
|
||||||
|
if extra_env:
|
||||||
|
env.update(extra_env)
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
||||||
|
adapter = SmsAdapter(pc)
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refuses_start_without_webhook_url(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
result = await adapter.connect()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insecure_flag_allows_start_without_url(self):
|
||||||
|
with patch.dict(os.environ, {"SMS_INSECURE_NO_SIGNATURE": "true"}):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
result = await adapter.connect()
|
||||||
|
assert result is True
|
||||||
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_url_allows_start(self):
|
||||||
|
adapter = self._make_adapter(
|
||||||
|
extra_env={"SMS_WEBHOOK_URL": "https://example.com/webhooks/twilio"}
|
||||||
|
)
|
||||||
|
result = await adapter.connect()
|
||||||
|
assert result is True
|
||||||
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Twilio signature validation ────────────────────────────────────
|
||||||
|
|
||||||
|
def _compute_twilio_signature(auth_token, url, params):
|
||||||
|
"""Reference implementation of Twilio's signature algorithm."""
|
||||||
|
data_to_sign = url
|
||||||
|
for key in sorted(params.keys()):
|
||||||
|
data_to_sign += key + params[key]
|
||||||
|
mac = hmac.new(
|
||||||
|
auth_token.encode("utf-8"),
|
||||||
|
data_to_sign.encode("utf-8"),
|
||||||
|
hashlib.sha1,
|
||||||
|
)
|
||||||
|
return base64.b64encode(mac.digest()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTwilioSignatureValidation:
|
||||||
|
"""Unit tests for SmsAdapter._validate_twilio_signature."""
|
||||||
|
|
||||||
|
def _make_adapter(self, auth_token="test_token_secret"):
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": auth_token,
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key=auth_token)
|
||||||
|
adapter = SmsAdapter(pc)
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
def test_valid_signature_accepted(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
url = "https://example.com/webhooks/twilio"
|
||||||
|
params = {"From": "+15551234567", "Body": "hello", "To": "+15550001111"}
|
||||||
|
sig = _compute_twilio_signature("test_token_secret", url, params)
|
||||||
|
assert adapter._validate_twilio_signature(url, params, sig) is True
|
||||||
|
|
||||||
|
def test_invalid_signature_rejected(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
url = "https://example.com/webhooks/twilio"
|
||||||
|
params = {"From": "+15551234567", "Body": "hello"}
|
||||||
|
assert adapter._validate_twilio_signature(url, params, "badsig") is False
|
||||||
|
|
||||||
|
def test_wrong_token_rejected(self):
|
||||||
|
adapter = self._make_adapter(auth_token="correct_token")
|
||||||
|
url = "https://example.com/webhooks/twilio"
|
||||||
|
params = {"From": "+15551234567", "Body": "hello"}
|
||||||
|
sig = _compute_twilio_signature("wrong_token", url, params)
|
||||||
|
assert adapter._validate_twilio_signature(url, params, sig) is False
|
||||||
|
|
||||||
|
def test_params_sorted_by_key(self):
|
||||||
|
"""Signature must be computed with params sorted alphabetically."""
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
url = "https://example.com/webhooks/twilio"
|
||||||
|
params = {"Zebra": "last", "Alpha": "first", "Middle": "mid"}
|
||||||
|
sig = _compute_twilio_signature("test_token_secret", url, params)
|
||||||
|
assert adapter._validate_twilio_signature(url, params, sig) is True
|
||||||
|
|
||||||
|
def test_empty_param_values_included(self):
|
||||||
|
"""Blank values must be included in signature computation."""
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
url = "https://example.com/webhooks/twilio"
|
||||||
|
params = {"From": "+15551234567", "Body": "", "SmsStatus": "received"}
|
||||||
|
sig = _compute_twilio_signature("test_token_secret", url, params)
|
||||||
|
assert adapter._validate_twilio_signature(url, params, sig) is True
|
||||||
|
|
||||||
|
def test_url_matters(self):
|
||||||
|
"""Different URLs produce different signatures."""
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
params = {"Body": "hello"}
|
||||||
|
sig = _compute_twilio_signature(
|
||||||
|
"test_token_secret", "https://a.com/webhooks/twilio", params
|
||||||
|
)
|
||||||
|
assert adapter._validate_twilio_signature(
|
||||||
|
"https://b.com/webhooks/twilio", params, sig
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_port_variant_443_matches_without_port(self):
|
||||||
|
"""Signature for https URL with :443 validates against URL without port."""
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
params = {"From": "+15551234567", "Body": "hello"}
|
||||||
|
sig = _compute_twilio_signature(
|
||||||
|
"test_token_secret", "https://example.com:443/webhooks/twilio", params
|
||||||
|
)
|
||||||
|
assert adapter._validate_twilio_signature(
|
||||||
|
"https://example.com/webhooks/twilio", params, sig
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_port_variant_without_port_matches_443(self):
|
||||||
|
"""Signature for https URL without port validates against URL with :443."""
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
params = {"From": "+15551234567", "Body": "hello"}
|
||||||
|
sig = _compute_twilio_signature(
|
||||||
|
"test_token_secret", "https://example.com/webhooks/twilio", params
|
||||||
|
)
|
||||||
|
assert adapter._validate_twilio_signature(
|
||||||
|
"https://example.com:443/webhooks/twilio", params, sig
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_non_standard_port_no_variant(self):
|
||||||
|
"""Non-standard port must NOT match URL without port."""
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
params = {"From": "+15551234567", "Body": "hello"}
|
||||||
|
sig = _compute_twilio_signature(
|
||||||
|
"test_token_secret", "https://example.com/webhooks/twilio", params
|
||||||
|
)
|
||||||
|
assert adapter._validate_twilio_signature(
|
||||||
|
"https://example.com:8080/webhooks/twilio", params, sig
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_port_variant_http_80(self):
|
||||||
|
"""Port variant also works for http with port 80."""
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
params = {"From": "+15551234567", "Body": "hello"}
|
||||||
|
sig = _compute_twilio_signature(
|
||||||
|
"test_token_secret", "http://example.com:80/webhooks/twilio", params
|
||||||
|
)
|
||||||
|
assert adapter._validate_twilio_signature(
|
||||||
|
"http://example.com/webhooks/twilio", params, sig
|
||||||
|
) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Webhook signature enforcement (handler-level) ──────────────────
|
||||||
|
|
||||||
|
class TestWebhookSignatureEnforcement:
|
||||||
|
"""Integration tests for signature validation in _handle_webhook."""
|
||||||
|
|
||||||
|
def _make_adapter(self, webhook_url=""):
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "test_token_secret",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
"SMS_WEBHOOK_URL": webhook_url,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key="test_token_secret")
|
||||||
|
adapter = SmsAdapter(pc)
|
||||||
|
adapter._message_handler = AsyncMock()
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
def _mock_request(self, body, headers=None):
|
||||||
|
request = MagicMock()
|
||||||
|
request.read = AsyncMock(return_value=body)
|
||||||
|
request.headers = headers or {}
|
||||||
|
return request
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insecure_flag_skips_validation(self):
|
||||||
|
"""With SMS_INSECURE_NO_SIGNATURE=true and no URL, requests are accepted."""
|
||||||
|
env = {"SMS_INSECURE_NO_SIGNATURE": "true"}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
adapter = self._make_adapter(webhook_url="")
|
||||||
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
||||||
|
request = self._mock_request(body)
|
||||||
|
resp = await adapter._handle_webhook(request)
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insecure_flag_with_url_still_validates(self):
|
||||||
|
"""When both SMS_WEBHOOK_URL and SMS_INSECURE_NO_SIGNATURE are set,
|
||||||
|
validation stays active (URL takes precedence)."""
|
||||||
|
adapter = self._make_adapter(webhook_url="https://example.com/webhooks/twilio")
|
||||||
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
||||||
|
request = self._mock_request(body, headers={})
|
||||||
|
resp = await adapter._handle_webhook(request)
|
||||||
|
assert resp.status == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_signature_returns_403(self):
|
||||||
|
adapter = self._make_adapter(webhook_url="https://example.com/webhooks/twilio")
|
||||||
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
||||||
|
request = self._mock_request(body, headers={})
|
||||||
|
resp = await adapter._handle_webhook(request)
|
||||||
|
assert resp.status == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_signature_returns_403(self):
|
||||||
|
adapter = self._make_adapter(webhook_url="https://example.com/webhooks/twilio")
|
||||||
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
||||||
|
request = self._mock_request(body, headers={"X-Twilio-Signature": "invalid"})
|
||||||
|
resp = await adapter._handle_webhook(request)
|
||||||
|
assert resp.status == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_signature_returns_200(self):
|
||||||
|
webhook_url = "https://example.com/webhooks/twilio"
|
||||||
|
adapter = self._make_adapter(webhook_url=webhook_url)
|
||||||
|
params = {
|
||||||
|
"From": "+15551234567",
|
||||||
|
"To": "+15550001111",
|
||||||
|
"Body": "hello",
|
||||||
|
"MessageSid": "SM123",
|
||||||
|
}
|
||||||
|
sig = _compute_twilio_signature("test_token_secret", webhook_url, params)
|
||||||
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
||||||
|
request = self._mock_request(body, headers={"X-Twilio-Signature": sig})
|
||||||
|
resp = await adapter._handle_webhook(request)
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_port_variant_signature_returns_200(self):
|
||||||
|
"""Signature computed with :443 should pass when URL configured without port."""
|
||||||
|
webhook_url = "https://example.com/webhooks/twilio"
|
||||||
|
adapter = self._make_adapter(webhook_url=webhook_url)
|
||||||
|
params = {
|
||||||
|
"From": "+15551234567",
|
||||||
|
"To": "+15550001111",
|
||||||
|
"Body": "hello",
|
||||||
|
"MessageSid": "SM123",
|
||||||
|
}
|
||||||
|
sig = _compute_twilio_signature(
|
||||||
|
"test_token_secret", "https://example.com:443/webhooks/twilio", params
|
||||||
|
)
|
||||||
|
body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123"
|
||||||
|
request = self._mock_request(body, headers={"X-Twilio-Signature": sig})
|
||||||
|
resp = await adapter._handle_webhook(request)
|
||||||
|
assert resp.status == 200
|
||||||
|
|||||||
@@ -193,9 +193,12 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||||||
| `SIGNAL_IGNORE_STORIES` | Ignore Signal stories/status updates |
|
| `SIGNAL_IGNORE_STORIES` | Ignore Signal stories/status updates |
|
||||||
| `SIGNAL_ALLOW_ALL_USERS` | Allow all Signal users without an allowlist |
|
| `SIGNAL_ALLOW_ALL_USERS` | Allow all Signal users without an allowlist |
|
||||||
| `TWILIO_ACCOUNT_SID` | Twilio Account SID (shared with telephony skill) |
|
| `TWILIO_ACCOUNT_SID` | Twilio Account SID (shared with telephony skill) |
|
||||||
| `TWILIO_AUTH_TOKEN` | Twilio Auth Token (shared with telephony skill) |
|
| `TWILIO_AUTH_TOKEN` | Twilio Auth Token (shared with telephony skill; also used for webhook signature validation) |
|
||||||
| `TWILIO_PHONE_NUMBER` | Twilio phone number in E.164 format (shared with telephony skill) |
|
| `TWILIO_PHONE_NUMBER` | Twilio phone number in E.164 format (shared with telephony skill) |
|
||||||
|
| `SMS_WEBHOOK_URL` | Public URL for Twilio signature validation — must match the webhook URL in Twilio Console (required) |
|
||||||
| `SMS_WEBHOOK_PORT` | Webhook listener port for inbound SMS (default: `8080`) |
|
| `SMS_WEBHOOK_PORT` | Webhook listener port for inbound SMS (default: `8080`) |
|
||||||
|
| `SMS_WEBHOOK_HOST` | Webhook bind address (default: `0.0.0.0`) |
|
||||||
|
| `SMS_INSECURE_NO_SIGNATURE` | Set to `true` to disable Twilio signature validation (local dev only — not for production) |
|
||||||
| `SMS_ALLOWED_USERS` | Comma-separated E.164 phone numbers allowed to chat |
|
| `SMS_ALLOWED_USERS` | Comma-separated E.164 phone numbers allowed to chat |
|
||||||
| `SMS_ALLOW_ALL_USERS` | Allow all SMS senders without an allowlist |
|
| `SMS_ALLOW_ALL_USERS` | Allow all SMS senders without an allowlist |
|
||||||
| `SMS_HOME_CHANNEL` | Phone number for cron job / notification delivery |
|
| `SMS_HOME_CHANNEL` | Phone number for cron job / notification delivery |
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ ngrok http 8080
|
|||||||
Set the resulting public URL as your Twilio webhook.
|
Set the resulting public URL as your Twilio webhook.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
**Set `SMS_WEBHOOK_URL` to the same URL you configured in Twilio.** This is required for Twilio signature validation — the adapter will refuse to start without it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Must match the webhook URL in your Twilio Console
|
||||||
|
SMS_WEBHOOK_URL=https://your-server:8080/webhooks/twilio
|
||||||
|
```
|
||||||
|
|
||||||
The webhook port defaults to `8080`. Override with:
|
The webhook port defaults to `8080`. Override with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -101,9 +108,11 @@ hermes gateway
|
|||||||
You should see:
|
You should see:
|
||||||
|
|
||||||
```
|
```
|
||||||
[sms] Twilio webhook server listening on port 8080, from: +1555***4567
|
[sms] Twilio webhook server listening on 0.0.0.0:8080, from: +1555***4567
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you see `Refusing to start: SMS_WEBHOOK_URL is required`, set `SMS_WEBHOOK_URL` to the public URL configured in your Twilio Console (see Step 3).
|
||||||
|
|
||||||
Text your Twilio number — Hermes will respond via SMS.
|
Text your Twilio number — Hermes will respond via SMS.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -113,9 +122,12 @@ Text your Twilio number — Hermes will respond via SMS.
|
|||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|----------|----------|-------------|
|
|----------|----------|-------------|
|
||||||
| `TWILIO_ACCOUNT_SID` | Yes | Twilio Account SID (starts with `AC`) |
|
| `TWILIO_ACCOUNT_SID` | Yes | Twilio Account SID (starts with `AC`) |
|
||||||
| `TWILIO_AUTH_TOKEN` | Yes | Twilio Auth Token |
|
| `TWILIO_AUTH_TOKEN` | Yes | Twilio Auth Token (also used for webhook signature validation) |
|
||||||
| `TWILIO_PHONE_NUMBER` | Yes | Your Twilio phone number (E.164 format) |
|
| `TWILIO_PHONE_NUMBER` | Yes | Your Twilio phone number (E.164 format) |
|
||||||
|
| `SMS_WEBHOOK_URL` | Yes | Public URL for Twilio signature validation — must match the webhook URL in your Twilio Console |
|
||||||
| `SMS_WEBHOOK_PORT` | No | Webhook listener port (default: `8080`) |
|
| `SMS_WEBHOOK_PORT` | No | Webhook listener port (default: `8080`) |
|
||||||
|
| `SMS_WEBHOOK_HOST` | No | Webhook bind address (default: `0.0.0.0`) |
|
||||||
|
| `SMS_INSECURE_NO_SIGNATURE` | No | Set to `true` to disable signature validation (local dev only — **not for production**) |
|
||||||
| `SMS_ALLOWED_USERS` | No | Comma-separated E.164 phone numbers allowed to chat |
|
| `SMS_ALLOWED_USERS` | No | Comma-separated E.164 phone numbers allowed to chat |
|
||||||
| `SMS_ALLOW_ALL_USERS` | No | Set to `true` to allow anyone (not recommended) |
|
| `SMS_ALLOW_ALL_USERS` | No | Set to `true` to allow anyone (not recommended) |
|
||||||
| `SMS_HOME_CHANNEL` | No | Phone number for cron job / notification delivery |
|
| `SMS_HOME_CHANNEL` | No | Phone number for cron job / notification delivery |
|
||||||
@@ -134,6 +146,21 @@ Text your Twilio number — Hermes will respond via SMS.
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
### Webhook signature validation
|
||||||
|
|
||||||
|
Hermes validates that inbound webhooks genuinely originate from Twilio by verifying the `X-Twilio-Signature` header (HMAC-SHA1). This prevents attackers from injecting forged messages.
|
||||||
|
|
||||||
|
**`SMS_WEBHOOK_URL` is required.** Set it to the public URL configured in your Twilio Console. The adapter will refuse to start without it.
|
||||||
|
|
||||||
|
For local development without a public URL, you can disable validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local dev only — NOT for production
|
||||||
|
SMS_INSECURE_NO_SIGNATURE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### User allowlists
|
||||||
|
|
||||||
**The gateway denies all users by default.** Configure an allowlist:
|
**The gateway denies all users by default.** Configure an allowlist:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user