mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 03:18:17 +08:00
fix(security): require auth on every MCP request; stop stale refreshes banning clients
- Remove the authless "sole connected phone" fallback and SB_REQUIRE_MCP_AUTH: an unauthenticated request no longer reaches whichever phone is online alone. - Mcp-Session-Id is no longer a credential; the Bearer token is checked on every request (MCP auth spec). - Refresh tokens live 90 days (was 30, equal to the access token, so they were always dead when first needed). - A rejected refresh no longer counts toward the IP ban: a client with an expired token was retrying into a self-renewing ban on its own IP. - 401s carry the RFC 9728 WWW-Authenticate discovery header.
This commit is contained in:
@@ -163,5 +163,49 @@ with TestClient(app) as client:
|
||||
j = r.json() if r.status_code == 200 else {}
|
||||
check("governor re-enables (one-way ratchet fixed)", j.get("governor_enabled") is True, r.text[:200])
|
||||
|
||||
# ── No authless fallback: a lone connected phone is NOT a credential ──
|
||||
import asyncio
|
||||
from server.session_registry import registry
|
||||
from server.auth import verify_token as _vt
|
||||
|
||||
class _FakeWS:
|
||||
async def send(self, data): pass
|
||||
async def close(self, code=1000, reason=""): pass
|
||||
|
||||
uid = _vt(access)["user_id"]
|
||||
asyncio.run(registry.register(uid, _FakeWS()))
|
||||
r = client.post("/mcp", json={"jsonrpc": "2.0", "id": 20, "method": "initialize", "params": {}})
|
||||
check("sole phone online: unauthenticated initialize still 401", r.status_code == 401, f"got {r.status_code}")
|
||||
r = client.post("/mcp", headers={"mcp-session-id": sess}, json={
|
||||
"jsonrpc": "2.0", "id": 21, "method": "tools/call",
|
||||
"params": {"name": "list_devices", "arguments": {}},
|
||||
})
|
||||
check("session id without token -> 401", r.status_code == 401, f"got {r.status_code}")
|
||||
check("401 carries WWW-Authenticate discovery header",
|
||||
"resource_metadata=" in r.headers.get("www-authenticate", ""), str(dict(r.headers))[:200])
|
||||
asyncio.run(registry.unregister(uid))
|
||||
|
||||
# ── Refresh: rotation works, dead refresh tokens don't ban the IP ──
|
||||
from server import oauth as _oauth
|
||||
from server import config as _cfg
|
||||
check("refresh token outlives access token",
|
||||
_oauth.REFRESH_TOKEN_EXPIRY_S > _cfg.TOKEN_EXPIRY_HOURS * 3600 * 2,
|
||||
f"refresh={_oauth.REFRESH_TOKEN_EXPIRY_S}s access={_cfg.TOKEN_EXPIRY_HOURS}h")
|
||||
rt = tok.get("refresh_token", "")
|
||||
r = client.post("/oauth/token", json={
|
||||
"grant_type": "refresh_token", "refresh_token": rt,
|
||||
"client_id": creds.get("client_id", ""), "client_secret": creds.get("client_secret", ""),
|
||||
})
|
||||
check("refresh grant rotates", r.status_code == 200 and r.json().get("refresh_token") not in ("", rt), r.text[:150])
|
||||
for _ in range(_cfg.BAN_THRESHOLD + 5):
|
||||
r = client.post("/oauth/token", json={
|
||||
"grant_type": "refresh_token", "refresh_token": rt, # now revoked
|
||||
"client_id": creds.get("client_id", ""), "client_secret": creds.get("client_secret", ""),
|
||||
})
|
||||
check("stale refresh -> invalid_grant, not a ban",
|
||||
r.status_code == 400 and r.json().get("error") == "invalid_grant", f"{r.status_code} {r.text[:100]}")
|
||||
r = client.post("/mcp", headers=hdrs, json={"jsonrpc": "2.0", "id": 22, "method": "tools/list"})
|
||||
check("same IP still served after repeated stale refreshes", r.status_code == 200, f"got {r.status_code}")
|
||||
|
||||
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
||||
Reference in New Issue
Block a user