Files
OpenJarvis/tests/server/test_auth_middleware.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

87 lines
2.3 KiB
Python

"""Tests for API key authentication middleware."""
from __future__ import annotations
import pytest
pytest.importorskip("fastapi", reason="openjarvis[server] not installed")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from openjarvis.server.auth_middleware import AuthMiddleware
def _make_app(api_key: str) -> FastAPI:
app = FastAPI()
app.add_middleware(AuthMiddleware, api_key=api_key)
@app.get("/v1/models")
async def models():
return {"models": []}
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/webhooks/twilio")
async def twilio_webhook():
return {"status": "received"}
@app.get("/metrics")
async def metrics():
return {"requests": 0}
return app
@pytest.fixture
def client():
return TestClient(_make_app("oj_sk_test123"))
class TestAuthMiddleware:
def test_rejects_missing_auth_header(self, client):
resp = client.get("/v1/models")
assert resp.status_code == 401
assert "missing" in resp.json()["detail"].lower()
def test_rejects_wrong_key(self, client):
resp = client.get(
"/v1/models",
headers={"Authorization": "Bearer wrong"},
)
assert resp.status_code == 401
assert "invalid" in resp.json()["detail"].lower()
def test_accepts_valid_key(self, client):
resp = client.get(
"/v1/models",
headers={"Authorization": "Bearer oj_sk_test123"},
)
assert resp.status_code == 200
def test_health_exempt(self, client):
resp = client.get("/health")
assert resp.status_code == 200
def test_webhooks_exempt(self, client):
resp = client.post("/webhooks/twilio")
assert resp.status_code == 200
def test_metrics_requires_auth(self, client):
resp = client.get("/metrics")
assert resp.status_code == 401
def test_metrics_accepts_valid_key(self, client):
resp = client.get(
"/metrics", headers={"Authorization": "Bearer oj_sk_test123"}
)
assert resp.status_code == 200
def test_no_key_configured_allows_all(self):
client = TestClient(_make_app(""))
resp = client.get("/v1/models")
assert resp.status_code == 200
assert client.get("/metrics").status_code == 200