Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

89 lines
2.8 KiB
Python

"""Behavioral tests for the MCP loopback Host-header guard (#1869)."""
from __future__ import annotations
import pytest
from notebooklm.mcp._host_guard import LoopbackHostGuardMiddleware
class _Recorder:
"""A trivial ASGI app that records whether it was reached."""
def __init__(self) -> None:
self.reached = False
async def __call__(self, scope, receive, send) -> None:
self.reached = True
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
def _http_scope(host: str | None) -> dict:
headers = [(b"host", host.encode())] if host is not None else []
return {"type": "http", "headers": headers}
async def _run(mw: LoopbackHostGuardMiddleware, scope: dict) -> int | None:
sent: list[dict] = []
async def receive() -> dict:
return {"type": "http.request", "body": b""}
async def send(message: dict) -> None:
sent.append(message)
await mw(scope, receive, send)
starts = [m for m in sent if m["type"] == "http.response.start"]
return starts[0]["status"] if starts else None
@pytest.mark.asyncio
@pytest.mark.parametrize("host", ["evil.example", "attacker.com:9420", "0.0.0.0", "", None])
async def test_non_loopback_host_is_rejected(host: str | None) -> None:
app = _Recorder()
status = await _run(LoopbackHostGuardMiddleware(app, allow_external=False), _http_scope(host))
assert status == 403
assert app.reached is False # request never reached the wrapped app
@pytest.mark.asyncio
@pytest.mark.parametrize(
"host", ["127.0.0.1", "127.0.0.1:9420", "localhost", "[::1]", "[::1]:9420"]
)
async def test_loopback_host_passes_through(host: str) -> None:
app = _Recorder()
status = await _run(LoopbackHostGuardMiddleware(app, allow_external=False), _http_scope(host))
assert status == 200
assert app.reached is True
@pytest.mark.asyncio
async def test_allow_external_bypasses_the_guard() -> None:
# When an external bind is opted into (auth is mandatory there), the guard must
# NOT reject a non-loopback Host — the bearer/OAuth layer owns access control.
app = _Recorder()
status = await _run(
LoopbackHostGuardMiddleware(app, allow_external=True), _http_scope("evil.example")
)
assert status == 200
assert app.reached is True
@pytest.mark.asyncio
async def test_non_http_scope_passes_through() -> None:
# Lifespan/websocket scopes have no Host to validate and must not be blocked.
app = _Recorder()
sent: list[dict] = []
async def receive() -> dict:
return {"type": "lifespan.startup"}
async def send(message: dict) -> None:
sent.append(message)
await LoopbackHostGuardMiddleware(app, allow_external=False)(
{"type": "lifespan"}, receive, send
)
assert app.reached is True