bifroest/services/db-mcp/dbmcp/core/connection.py
2026-10-27 08:34:17 +01:00

77 lines
2.8 KiB
Python

"""Zugangsdaten je Aufruf aus HTTP-Headern.
Der Dienst speichert keine Zugangsdaten. Der Client (z. B. Bifröst) schickt sie
mit jedem Aufruf als Header; das Modell sieht sie nie, weil sie nicht in den
Tool-Argumenten stehen. Erlaubt sind nur freigegebene Hosts (Schutz vor SSRF).
X-DB-Host, X-DB-Port, X-DB-Database, X-DB-User, X-DB-Password, X-DB-SSLMode
"""
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from dbmcp.core import Invalid
from dbmcp.core.config import Settings
HOST = re.compile(r"^[a-z0-9.-]{1,253}$")
IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_$.-]{0,127}$")
LOCAL = {"localhost", "127.0.0.1", "::1"}
SSL_MODES = ("require", "verify-full", "prefer", "disable")
@dataclass(frozen=True)
class Connection:
host: str
port: int
database: str
user: str
password: str = field(repr=False)
sslmode: str
@property
def local(self) -> bool:
return self.host in LOCAL
def describe(self) -> dict[str, str | int]:
"""Für das Audit-Log: alles außer dem Passwort."""
return {"host": self.host, "port": self.port, "database": self.database, "user": self.user}
def allowed(host: str, settings: Settings) -> bool:
if host in LOCAL:
return settings.allow_local
return any(host == entry or (entry.startswith(".") and host.endswith(entry)) for entry in settings.allowed_hosts)
def from_headers(headers: Mapping[str, str] | None, settings: Settings, default_port: int) -> Connection:
headers = {k.lower(): v for k, v in (headers or {}).items()}
def value(name: str, required: bool = True) -> str:
found = headers.get(f"x-db-{name}", "").strip()
if required and not found:
raise Invalid(f"Header X-DB-{name.title()} fehlt.")
return found
host = value("host").lower()
if not HOST.match(host) and host != "::1":
raise Invalid("Ungültiger Host.")
if not allowed(host, settings):
raise Invalid(f"Host {host} ist nicht freigegeben (DBMCP_ALLOWED_HOSTS).")
try:
port = int(value("port", required=False) or default_port)
except ValueError:
raise Invalid("Der Port muss eine Zahl sein.") from None
if not 1 <= port <= 65535:
raise Invalid("Ungültiger Port.")
database, user = value("database"), value("user")
if not IDENTIFIER.match(database):
raise Invalid("Ungültiger Datenbankname.")
if not IDENTIFIER.match(user):
raise Invalid("Ungültiger Benutzername.")
sslmode = value("sslmode", required=False) or "verify-full"
if sslmode not in SSL_MODES:
raise Invalid("Unbekannter SSL-Modus.")
if sslmode in ("prefer", "disable") and host not in LOCAL:
raise Invalid("Ohne erzwungenes TLS nur für lokale Datenbanken.")
return Connection(host, port, database, user, value("password", required=False), sslmode)