feat(mcp): שער Bearer לתחבורת ה-HTTP — מסרב לעלות בלי טוקן (#231.2)
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 11s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 4s
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s

ב-stdio ההגנה היא הצינור עצמו: רק תהליך שכבר רץ כמשתמש הזה יכול לדבר עם
השרת. streamable-http מבטל את התכונה הזו לחלוטין — כל מי שמגיע ל-socket
יכול לקרוא לכל אחד מ-108 הכלים, ובמרשם יש case_delete,
precedent_library_delete, document_upload וכל כלי כתיבת-הבלוקים. מאזין
לא-מאומת הוא, הלכה למעשה, endpoint למחיקת תיקים.

מימוש דרך ה-TokenVerifier של ה-SDK ו-BearerAuthBackend שלו — לא middleware
משלנו. מסלול-אימות אחד, של המסגרת (G2).

ההחלטות שקובעות את בטיחות הפיצ'ר:
- **מסרב לעלות בלי טוקן.** MissingTokenError קטלנית. החלופה המפתה — לעלות
  ולרשום warning — מייצרת מאזין שנראה בריא ועונה על כל קריאה הרסנית.
  סירוב-אתחול הוא הכשל הבטוח (§6).
- הבנייה בזמן-import ולא בתוך main(): FastMCP מקבל token_verifier ו-auth
  כארגומנטי-בנאי, ולכן טוקן חסר חייב להיכשל *לפני* שה-listener קיים.
- **stdio לא נוגע.** דרישת טוקן שם הייתה שוברת כל סשן אינטראקטיבי בלי שום
  רווח אבטחתי — הגבול שם הוא הצינור.
- השוואה בזמן-קבוע (hmac.compare_digest); == נאיבי מדליף את הטוקן בייט-בייט
  לתוקף שמודד זמנים.
- verify_token מחזיר None ולא זורק — זה אות ה"דחייה" של הפרוטוקול ומניב 401
  נקי במקום 500 שנקרא ככשל-שרת.
- סף אורך 32 תווים; טוקן קצר נדחה באתחול ולא מתגלה מ-access log.
- הטוקן נקרא מ-env (שיאוכלס מ-Infisical), לא מוטמע, ולא נרשם ללוג.

אומת בהרצה חיה:
  HTTP בלי טוקן → סירוב לעלות, exit 1
  stdio בלי טוקן → עולה כרגיל, auth כבוי
  POST בלי Authorization        → 401
  POST עם טוקן שגוי             → 401
  POST עם הטוקן הנכון           → 200
  claude דרך HTTP+Bearer        → 108 כלים, mcp__legal-ai__case_get
  מופעי הטוקן בלוג              → 0

invariants: G2 — מקיים (מסלול-אימות יחיד, של ה-SDK). G12 — מקיים; המודול
נקי מסמלי-פלטפורמה, leak_guard ירוק. INV-AG3 — לא נגוע, השער ירוק.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 07:45:38 +00:00
parent 6aac4a73f6
commit eebb193fd3
2 changed files with 131 additions and 2 deletions

View File

@@ -0,0 +1,101 @@
"""Bearer-token gate for the HTTP transport of the MCP server (#231.2).
Why this exists
---------------
Over ``stdio`` the protection is the pipe itself: only a process that already
runs as this user can speak to the server. ``streamable-http`` removes that
property entirely — anything that can reach the socket can call any of the 108
registered tools, and the registry includes ``case_delete``,
``precedent_library_delete``, ``document_upload`` and every block-writing tool.
An unauthenticated listener is therefore a delete-any-case endpoint.
The agent platform already speaks this dialect: it injects each runtime MCP
server as ``{type:"http", name, url, headers:[{name:"Authorization",
value:"Bearer <token>"}]}``. So a static bearer token is exactly the shape the
caller will present — no negotiation, no OAuth dance.
Design notes
------------
- We implement the SDK's own ``TokenVerifier`` protocol and let
``BearerAuthBackend`` do the enforcement, rather than adding bespoke
middleware. One auth path, the framework's (G2).
- The token is read from the environment, which the service unit populates from
Infisical. It is never defaulted, never logged, and never embedded here.
- Comparison is constant-time: a naive ``==`` leaks the token byte-by-byte to a
caller who can time responses.
- ``verify_token`` returns ``None`` (not an exception) on mismatch — that is the
protocol's "reject" signal and yields a clean 401 instead of a 500 that would
read as a server fault.
"""
from __future__ import annotations
import hmac
import logging
import os
from mcp.server.auth.provider import AccessToken, TokenVerifier
logger = logging.getLogger("legal_mcp.http_auth")
#: Environment variable carrying the shared bearer token. Populated from
#: Infisical by the service unit — see docs/spec/X10-deploy-env-secrets.md.
TOKEN_ENV = "MCP_HTTP_TOKEN"
#: Minimum acceptable token length. Short tokens are brute-forceable; refusing
#: them at startup is cheaper than discovering it from an access log.
MIN_TOKEN_LEN = 32
#: Reported as the authenticated principal. Single shared token today, so this
#: is a constant rather than a real identity — kept explicit so that audit rows
#: never imply per-agent attribution we cannot actually make.
CLIENT_ID = "mcp-http-shared"
class MissingTokenError(RuntimeError):
"""Raised when the HTTP transport is requested without a usable token.
Deliberately fatal. The tempting alternative — start anyway and log a
warning — produces a listener that looks healthy and answers every
destructive tool call. Refusing to boot is the safe failure (§6: never
swallow, never degrade silently).
"""
def load_token() -> str:
"""Return the configured bearer token, or raise if it is unusable."""
token = (os.environ.get(TOKEN_ENV) or "").strip()
if not token:
raise MissingTokenError(
f"{TOKEN_ENV} is not set. The HTTP transport exposes destructive "
f"tools and will not start without a bearer token. Set it from "
f"Infisical, or use MCP_TRANSPORT=stdio.",
)
if len(token) < MIN_TOKEN_LEN:
raise MissingTokenError(
f"{TOKEN_ENV} is shorter than {MIN_TOKEN_LEN} characters — refusing "
f"to start. Generate a long random token.",
)
return token
class StaticTokenVerifier(TokenVerifier):
"""Verifies the single shared bearer token presented by the platform.
Not an identity system: it answers "may this caller in at all", not "who is
it". Per-agent attribution would need per-agent tokens, which is a later
step once profiles exist (#231.4).
"""
def __init__(self, expected: str) -> None:
self._expected = expected
async def verify_token(self, token: str) -> AccessToken | None:
# compare_digest over bytes; it tolerates unequal lengths without
# short-circuiting, which is the whole point.
if not hmac.compare_digest(token.encode("utf-8"),
self._expected.encode("utf-8")):
# No token material in the log line — only the fact of a rejection.
logger.warning("Rejected MCP HTTP request: bearer token mismatch")
return None
return AccessToken(token=token, client_id=CLIENT_ID, scopes=[])