Critical: the MCP SDK auto-enables DNS-rebinding protection when its host parameter is left at the 127.0.0.1 default, hard-coding a loopback-only Host allow-list. Every remote client (the only deployment this feature targets) was refused with HTTP 421 before auth or the tool ran. Now driven by a new MCP_ALLOWED_HOSTS setting, with '*' as an explicit, logged opt-out. Also bounds query/top_k to match AskRequest (top_k is amplified 4x downstream, so an unbounded value was a resource-exhaustion vector), decodes the Authorization header as latin-1 per the ASGI spec instead of raising a 500 on malformed bytes, and returns WWW-Authenticate on 401 per RFC 7235. Moves the psycopg2 import guard into backend/tests/conftest.py: duplicated across four test modules, it only worked because of alphabetical collection order, and any earlier-sorting package would have reintroduced a live connection attempt against the production database. Registers the mcp module in the authoritative backend architecture doc. 84 backend tests pass. Verified against a live server: allowed remote Host returns a valid initialize result, unknown Host returns 421, missing token returns 401 with WWW-Authenticate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
94 lines
3.8 KiB
Python
94 lines
3.8 KiB
Python
"""Unit tests for MCPAuthMiddleware.
|
|
|
|
Wraps a minimal dummy ASGI app (not the real MCP app) so these tests exercise
|
|
only the auth gate, not the MCP protocol itself — keeps the test fast and
|
|
independent of FastMCP internals.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
from starlette.applications import Starlette
|
|
from starlette.responses import PlainTextResponse
|
|
from starlette.routing import Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from app.mcp.server import MCPAuthMiddleware
|
|
|
|
|
|
def _dummy_app() -> Starlette:
|
|
"""Build a minimal Starlette app that MCPAuthMiddleware can wrap."""
|
|
async def _ok(request):
|
|
"""Return a fixed 200 response so tests can assert pass-through."""
|
|
return PlainTextResponse("ok")
|
|
|
|
app = Starlette(routes=[Route("/ping", _ok)])
|
|
app.add_middleware(MCPAuthMiddleware)
|
|
return app
|
|
|
|
|
|
def test_missing_token_rejected_when_auth_enabled():
|
|
"""No Authorization header + auth_enabled=True -> 401."""
|
|
with patch("app.mcp.server.settings") as fake_settings:
|
|
fake_settings.auth_enabled = True
|
|
client = TestClient(_dummy_app())
|
|
response = client.get("/ping")
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_invalid_token_rejected_when_auth_enabled():
|
|
"""A token that fails decode_token() -> 401, request never reaches the app."""
|
|
fake_handler = type("H", (), {"decode_token": lambda self, t: (_ for _ in ()).throw(ValueError("bad token"))})()
|
|
with patch("app.mcp.server.settings") as fake_settings, \
|
|
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
|
|
fake_settings.auth_enabled = True
|
|
client = TestClient(_dummy_app())
|
|
response = client.get("/ping", headers={"Authorization": "Bearer garbage"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_valid_token_passes_through_when_auth_enabled():
|
|
"""A token that decodes successfully -> request reaches the wrapped app."""
|
|
fake_handler = type("H", (), {"decode_token": lambda self, t: object()})()
|
|
with patch("app.mcp.server.settings") as fake_settings, \
|
|
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
|
|
fake_settings.auth_enabled = True
|
|
client = TestClient(_dummy_app())
|
|
response = client.get("/ping", headers={"Authorization": "Bearer good"})
|
|
assert response.status_code == 200
|
|
assert response.text == "ok"
|
|
|
|
|
|
def test_auth_disabled_always_passes_through():
|
|
"""auth_enabled=False (dev mode) -> no token needed, matches get_current_user's dev bypass."""
|
|
with patch("app.mcp.server.settings") as fake_settings:
|
|
fake_settings.auth_enabled = False
|
|
client = TestClient(_dummy_app())
|
|
response = client.get("/ping")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_401_includes_www_authenticate_header():
|
|
"""RFC 7235 requires WWW-Authenticate on 401 so clients can tell why they failed."""
|
|
with patch("app.mcp.server.settings") as fake_settings:
|
|
fake_settings.auth_enabled = True
|
|
client = TestClient(_dummy_app())
|
|
response = client.get("/ping")
|
|
assert response.status_code == 401
|
|
assert response.headers["WWW-Authenticate"] == "Bearer"
|
|
|
|
|
|
def test_non_utf8_authorization_header_is_rejected_not_crashed():
|
|
"""A non-UTF-8 header byte must yield a clean 401, not an unhandled 500.
|
|
|
|
ASGI header values are latin-1 bytes, so any remote client could otherwise
|
|
trigger a UnicodeDecodeError inside the middleware at will.
|
|
"""
|
|
with patch("app.mcp.server.settings") as fake_settings:
|
|
fake_settings.auth_enabled = True
|
|
client = TestClient(_dummy_app(), raise_server_exceptions=False)
|
|
# Bypass the http client's own header encoding by writing raw bytes.
|
|
response = client.get("/ping", headers={"Authorization": b"Bearer \xff\xfe"})
|
|
assert response.status_code == 401
|