Compare commits

...

6 Commits

Author SHA1 Message Date
Mariano Nicolini
5825f2c7e0 add new test covering edge case where both insecure_no_sig and _webhook_url are set 2026-04-11 16:29:04 -03:00
Mariano Nicolini
1e0b4006ca remove unused import and fix misleading log 2026-04-11 16:25:14 -03:00
Mariano Nicolini
40f5b70c6c update docks with changes made 2026-04-11 16:09:27 -03:00
Mariano Nicolini
e339d3bb87 change Twilio signature verification from opt-in to opt-out 2026-04-11 15:46:24 -03:00
Mariano Nicolini
ee81a53cb6 handle port variants in Twilio signatures 2026-04-11 15:15:33 -03:00
Mariano Nicolini
5eae976c40 add basic twilio signature checking and tests 2026-04-11 15:11:42 -03:00
4 changed files with 477 additions and 8 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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 |

View File

@@ -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