Files
OpenJarvis/tests/server/test_sendblue_webhook.py
T
d7053c35d5 security: harden network-exposed surface (#509)
* security: harden network-exposed surface

Hardening for the network-reachable attack surface, prioritizing fixes
that are strong but do not change working local/loopback defaults.

- auth_middleware: constant-time API key comparison (secrets.compare_digest)
  for the HTTP path, and gate /metrics behind auth so operational counters
  are not readable unauthenticated. /health stays open.
- webhook_routes: fail closed when a channel's secret/token is unset. Twilio,
  BlueBubbles, WhatsApp (verify + inbound), and SendBlue now reject (403)
  instead of processing unsigned/unauthenticated input. Constant-time
  comparisons for BlueBubbles/SendBlue/WhatsApp verify token.
- http_request: follow redirects manually and re-run the SSRF check on every
  hop (capped at 5) so an allowed public URL cannot 30x-redirect to an
  internal/metadata address.
- api_routes /v1/memory/index: restrict indexing to OPENJARVIS_WORKSPACE roots
  when configured and refuse sensitive files (.env, keys, credentials).
- config.toml: default [server] host to 127.0.0.1 (loopback) with a comment
  on how to safely expose to a LAN (0.0.0.0 + API key).

Tests: new fail-closed webhook tests, /metrics auth tests, and SSRF
redirect block/follow tests; updated SendBlue tests for the new
secret-required behavior. Affected suites pass (95 tests), ruff clean.

* fix(http): keep SSRF redirect-following patchable via httpx.request

The manual redirect-following loop used a private httpx.Client, which
bypassed the `http_request.httpx.request` mock seam that consumers' tests
rely on (e.g. the twitter-bot GitHub-issue tests escaped to the real
network and 401'd). Issue each hop via module-level httpx.request with
follow_redirects=False instead — same per-hop SSRF re-check, restored
testability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:32:28 -07:00

263 lines
8.3 KiB
Python

"""Integration tests for the SendBlue webhook endpoint.
Tests the /webhooks/sendblue route, health check endpoint, and the
full flow from incoming webhook -> bridge -> agent -> send response.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
pytest.importorskip("fastapi", reason="openjarvis[server] not installed")
from fastapi import FastAPI # noqa: E402
from starlette.testclient import TestClient # noqa: E402
from openjarvis.core.registry import ChannelRegistry # noqa: E402
@pytest.fixture(autouse=True)
def _register_sendblue():
if not ChannelRegistry.contains("sendblue"):
from openjarvis.channels.sendblue import SendBlueChannel
ChannelRegistry.register_value("sendblue", SendBlueChannel)
@pytest.fixture
def mock_bridge():
bridge = MagicMock()
bridge.handle_incoming.return_value = "Here are your results..."
return bridge
@pytest.fixture
def sendblue_channel():
from openjarvis.channels.sendblue import SendBlueChannel
ch = SendBlueChannel(
api_key_id="test_key",
api_secret_key="test_secret",
from_number="+15551234567",
# Webhooks now fail closed without a secret, so configure one and have
# the test client send the matching header by default.
webhook_secret="testsecret",
)
ch.connect()
return ch
@pytest.fixture
def webhook_app(mock_bridge, sendblue_channel):
from openjarvis.server.webhook_routes import create_webhook_router
app = FastAPI()
router = create_webhook_router(
bridge=mock_bridge,
sendblue_channel=sendblue_channel,
)
app.include_router(router)
return app
@pytest.fixture
def client(webhook_app):
# Send the webhook secret by default so message-handling tests reach the
# bridge; fail-closed behavior is covered separately below.
return TestClient(webhook_app, headers={"x-sendblue-secret": "testsecret"})
# ---------------------------------------------------------------------------
# Webhook endpoint
# ---------------------------------------------------------------------------
class TestSendBlueWebhook:
def test_incoming_message_returns_200(self, client):
resp = client.post(
"/webhooks/sendblue",
json={
"from_number": "+19127130720",
"to_number": "+15551234567",
"content": "Hello Jarvis",
"message_handle": "msg-001",
"is_outbound": False,
"status": "RECEIVED",
"service": "iMessage",
},
)
assert resp.status_code == 200
def test_outbound_status_callback_ignored(self, client, mock_bridge):
resp = client.post(
"/webhooks/sendblue",
json={
"from_number": "+15551234567",
"content": "Sent message",
"is_outbound": True,
},
)
assert resp.status_code == 200
mock_bridge.handle_incoming.assert_not_called()
def test_empty_content_ignored(self, client, mock_bridge):
resp = client.post(
"/webhooks/sendblue",
json={
"from_number": "+19127130720",
"content": "",
"is_outbound": False,
},
)
assert resp.status_code == 200
mock_bridge.handle_incoming.assert_not_called()
def test_missing_from_number_ignored(self, client, mock_bridge):
resp = client.post(
"/webhooks/sendblue",
json={
"content": "Hello",
"is_outbound": False,
},
)
assert resp.status_code == 200
mock_bridge.handle_incoming.assert_not_called()
def test_webhook_secret_validation(self, mock_bridge):
"""When a webhook secret is set, reject requests without it."""
from openjarvis.channels.sendblue import SendBlueChannel
from openjarvis.server.webhook_routes import create_webhook_router
ch = SendBlueChannel(
api_key_id="k",
api_secret_key="s",
from_number="+1555",
webhook_secret="mysecret",
)
ch.connect()
app = FastAPI()
router = create_webhook_router(bridge=mock_bridge, sendblue_channel=ch)
app.include_router(router)
c = TestClient(app)
# Without secret header -> rejected
resp = c.post(
"/webhooks/sendblue",
json={
"from_number": "+19127130720",
"content": "Hello",
"is_outbound": False,
},
)
assert resp.status_code == 403
# With correct secret -> accepted
resp = c.post(
"/webhooks/sendblue",
json={
"from_number": "+19127130720",
"content": "Hello",
"is_outbound": False,
"message_handle": "msg-002",
},
headers={"x-sendblue-secret": "mysecret"},
)
assert resp.status_code == 200
def test_no_bridge_returns_200(self, sendblue_channel):
"""When no bridge exists, webhook should not crash."""
from openjarvis.server.webhook_routes import create_webhook_router
app = FastAPI()
router = create_webhook_router(bridge=None, sendblue_channel=sendblue_channel)
app.include_router(router)
c = TestClient(app, headers={"x-sendblue-secret": "testsecret"})
resp = c.post(
"/webhooks/sendblue",
json={
"from_number": "+19127130720",
"content": "Hello",
"is_outbound": False,
},
)
assert resp.status_code == 200
def test_no_secret_configured_is_rejected(self, mock_bridge):
"""Fail closed: a channel without a webhook_secret rejects all posts."""
from openjarvis.channels.sendblue import SendBlueChannel
from openjarvis.server.webhook_routes import create_webhook_router
ch = SendBlueChannel(
api_key_id="k", api_secret_key="s", from_number="+1555"
)
ch.connect()
app = FastAPI()
router = create_webhook_router(bridge=mock_bridge, sendblue_channel=ch)
app.include_router(router)
c = TestClient(app)
resp = c.post(
"/webhooks/sendblue",
json={"from_number": "+19127130720", "content": "Hi", "is_outbound": False},
)
assert resp.status_code == 403
mock_bridge.handle_incoming.assert_not_called()
# ---------------------------------------------------------------------------
# Health endpoint (requires agent_manager_routes)
# ---------------------------------------------------------------------------
class TestSendBlueHealth:
@pytest.fixture
def health_app(self, sendblue_channel):
app = FastAPI()
app.state.sendblue_channel = sendblue_channel
app.state.channel_bridge = MagicMock()
app.state.channel_bridge._channels = {"sendblue": sendblue_channel}
from openjarvis.server.agent_manager_routes import (
create_agent_manager_router,
)
mgr = MagicMock()
mgr.list_agents.return_value = []
routers = create_agent_manager_router(mgr)
sendblue_router = routers[4] # 5th element is sendblue_router
app.include_router(sendblue_router)
return app
def test_health_ready(self, health_app):
c = TestClient(health_app)
resp = c.get("/v1/channels/sendblue/health")
assert resp.status_code == 200
data = resp.json()
assert data["channel_connected"] is True
assert data["bridge_wired"] is True
assert data["ready"] is True
def test_health_not_ready(self):
app = FastAPI()
# No sendblue_channel or bridge on state
from openjarvis.server.agent_manager_routes import (
create_agent_manager_router,
)
mgr = MagicMock()
mgr.list_agents.return_value = []
routers = create_agent_manager_router(mgr)
sendblue_router = routers[4]
app.include_router(sendblue_router)
c = TestClient(app)
resp = c.get("/v1/channels/sendblue/health")
assert resp.status_code == 200
data = resp.json()
assert data["ready"] is False