77 lines
2.8 KiB
Python
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)
|