mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
* 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>
261 lines
8.0 KiB
Python
261 lines
8.0 KiB
Python
"""Tests for webhook routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("fastapi", reason="openjarvis[server] not installed")
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from openjarvis.server.webhook_routes import create_webhook_router
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_bridge():
|
|
bridge = MagicMock()
|
|
bridge.handle_incoming.return_value = "Got it!"
|
|
return bridge
|
|
|
|
|
|
class TestTwilioWebhook:
|
|
@pytest.fixture
|
|
def twilio_app(self, mock_bridge):
|
|
app = FastAPI()
|
|
router = create_webhook_router(
|
|
bridge=mock_bridge,
|
|
twilio_auth_token="test_token",
|
|
)
|
|
app.include_router(router)
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def twilio_client(self, twilio_app):
|
|
return TestClient(twilio_app)
|
|
|
|
def test_valid_twilio_webhook(self, twilio_client, mock_bridge):
|
|
with patch(
|
|
"openjarvis.server.webhook_routes._validate_twilio_signature",
|
|
return_value=True,
|
|
):
|
|
resp = twilio_client.post(
|
|
"/webhooks/twilio",
|
|
data={
|
|
"From": "+15551234567",
|
|
"Body": "hello jarvis",
|
|
"MessageSid": "SM123",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "<Response>" in resp.text # TwiML
|
|
|
|
def test_invalid_signature_rejected(self, twilio_client):
|
|
with patch(
|
|
"openjarvis.server.webhook_routes._validate_twilio_signature",
|
|
return_value=False,
|
|
):
|
|
resp = twilio_client.post(
|
|
"/webhooks/twilio",
|
|
data={
|
|
"From": "+15551234567",
|
|
"Body": "hello",
|
|
"MessageSid": "SM123",
|
|
},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
class TestBlueBubblesWebhook:
|
|
@pytest.fixture
|
|
def bb_app(self, mock_bridge):
|
|
app = FastAPI()
|
|
router = create_webhook_router(
|
|
bridge=mock_bridge,
|
|
bluebubbles_password="bb_secret",
|
|
)
|
|
app.include_router(router)
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def bb_client(self, bb_app):
|
|
return TestClient(bb_app)
|
|
|
|
def test_valid_bluebubbles_webhook(self, bb_client, mock_bridge):
|
|
resp = bb_client.post(
|
|
"/webhooks/bluebubbles",
|
|
json={
|
|
"type": "new-message",
|
|
"data": {
|
|
"handle": {"address": "user@icloud.com"},
|
|
"text": "hello from imessage",
|
|
"guid": "msg-123",
|
|
},
|
|
},
|
|
headers={"Authorization": "bb_secret"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_wrong_password_rejected(self, bb_client):
|
|
resp = bb_client.post(
|
|
"/webhooks/bluebubbles",
|
|
json={"type": "new-message", "data": {}},
|
|
headers={"Authorization": "wrong_password"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
class TestWhatsAppWebhook:
|
|
@pytest.fixture
|
|
def wa_app(self, mock_bridge):
|
|
app = FastAPI()
|
|
router = create_webhook_router(
|
|
bridge=mock_bridge,
|
|
whatsapp_verify_token="wa_verify_123",
|
|
whatsapp_app_secret="wa_secret",
|
|
)
|
|
app.include_router(router)
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def wa_client(self, wa_app):
|
|
return TestClient(wa_app)
|
|
|
|
def test_verification_challenge(self, wa_client):
|
|
resp = wa_client.get(
|
|
"/webhooks/whatsapp",
|
|
params={
|
|
"hub.mode": "subscribe",
|
|
"hub.verify_token": "wa_verify_123",
|
|
"hub.challenge": "challenge_string_42",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.text == "challenge_string_42"
|
|
|
|
def test_verification_wrong_token(self, wa_client):
|
|
resp = wa_client.get(
|
|
"/webhooks/whatsapp",
|
|
params={
|
|
"hub.mode": "subscribe",
|
|
"hub.verify_token": "wrong_token",
|
|
"hub.challenge": "challenge_string_42",
|
|
},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
def test_invalid_signature_rejected(self, wa_client):
|
|
payload = {
|
|
"entry": [
|
|
{
|
|
"changes": [
|
|
{
|
|
"value": {
|
|
"messages": [
|
|
{
|
|
"from": "123",
|
|
"text": {"body": "hi"},
|
|
"id": "x",
|
|
"type": "text",
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
body_bytes = json.dumps(payload).encode()
|
|
resp = wa_client.post(
|
|
"/webhooks/whatsapp",
|
|
content=body_bytes,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Hub-Signature-256": "sha256=invalid",
|
|
},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
def test_valid_message_webhook(self, wa_client, mock_bridge):
|
|
payload = {
|
|
"entry": [
|
|
{
|
|
"changes": [
|
|
{
|
|
"value": {
|
|
"messages": [
|
|
{
|
|
"from": "15551234567",
|
|
"text": {"body": "hello wa"},
|
|
"id": "wamid.abc123",
|
|
"type": "text",
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
body_bytes = json.dumps(payload).encode()
|
|
sig = hmac.new(b"wa_secret", body_bytes, hashlib.sha256).hexdigest()
|
|
resp = wa_client.post(
|
|
"/webhooks/whatsapp",
|
|
content=body_bytes,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Hub-Signature-256": f"sha256={sig}",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestWebhooksFailClosed:
|
|
"""When a channel's secret/token is unset, webhooks must reject (403)."""
|
|
|
|
def _client(self, mock_bridge, **kwargs):
|
|
app = FastAPI()
|
|
app.include_router(create_webhook_router(bridge=mock_bridge, **kwargs))
|
|
return TestClient(app)
|
|
|
|
def test_twilio_without_token_rejected(self, mock_bridge):
|
|
c = self._client(mock_bridge) # no twilio_auth_token
|
|
resp = c.post(
|
|
"/webhooks/twilio",
|
|
data={"From": "+15551234567", "Body": "hi", "MessageSid": "SM1"},
|
|
)
|
|
assert resp.status_code == 403
|
|
mock_bridge.handle_incoming.assert_not_called()
|
|
|
|
def test_bluebubbles_without_password_rejected(self, mock_bridge):
|
|
c = self._client(mock_bridge) # no bluebubbles_password
|
|
resp = c.post(
|
|
"/webhooks/bluebubbles",
|
|
json={"type": "new-message", "data": {}},
|
|
headers={"Authorization": "anything"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
def test_whatsapp_without_secret_rejected(self, mock_bridge):
|
|
c = self._client(mock_bridge) # no whatsapp_app_secret
|
|
resp = c.post(
|
|
"/webhooks/whatsapp",
|
|
content=b"{}",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
def test_whatsapp_verify_without_token_rejected(self, mock_bridge):
|
|
c = self._client(mock_bridge) # no whatsapp_verify_token
|
|
resp = c.get(
|
|
"/webhooks/whatsapp",
|
|
params={"hub.mode": "subscribe", "hub.verify_token": "",
|
|
"hub.challenge": "x"},
|
|
)
|
|
assert resp.status_code == 403
|