Sec: PC-Executor Bearer-Token-Pflicht (RCE-Lücke schließen)

executor.py erzwingt jetzt HERMES_PC_TOKEN auf allen Steuer-Endpunkten
(/shell,/screenshot,/type,/key,/open,/search), fail-closed (503) wenn kein
Token gesetzt ist; /health bleibt offen für den Reachability-Check. CORS-
Wildcard entfernt, Bind-Host konfigurierbar (HERMES_PC_HOST). mcp_pc.py sendet
PC_EXECUTOR_TOKEN als Authorization-Bearer mit.

Schließt die unauthentifizierte Remote-Code-Execution auf dem Windows-PC
(host=0.0.0.0, kein Token) — Teil von Stufe 0 (Security) des Stack-Reviews.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-29 16:46:41 +02:00
parent 50e04e0aee
commit b31736cde7
2 changed files with 38 additions and 13 deletions
+33 -11
View File
@@ -3,6 +3,7 @@ Hermes PC Executor — läuft auf dem Windows PC.
Empfängt Tool-Befehle vom MCP-Server der AI Box und führt sie lokal aus. Empfängt Tool-Befehle vom MCP-Server der AI Box und führt sie lokal aus.
""" """
import base64 import base64
import hmac
import io import io
import os import os
import socket import socket
@@ -11,18 +12,38 @@ import webbrowser
from urllib.parse import quote from urllib.parse import quote
import uvicorn import uvicorn
from fastapi import FastAPI, HTTPException from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Hermes PC Executor") app = FastAPI(title="Hermes PC Executor")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
MY_PORT = int(os.environ.get("HERMES_PC_PORT", "7777")) MY_PORT = int(os.environ.get("HERMES_PC_PORT", "7777"))
# Auf welchem Interface lauschen. Default 0.0.0.0 (LAN), per Env einschränkbar (z.B. die LAN-IP des PCs).
MY_HOST = os.environ.get("HERMES_PC_HOST", "0.0.0.0")
# ── Auth ───────────────────────────────────────────────────────────────────
# Shared Secret. OHNE Token sind die gefährlichen Endpunkte (Shell/Eingabe/Öffnen/Screenshot)
# fail-closed gesperrt → ein un-konfigurierter Executor ist KEINE offene Remote-Code-Execution mehr.
# Der Token muss identisch auf der Box (Hermes-Env PC_EXECUTOR_TOKEN → mcp_pc.py) gesetzt sein.
AUTH_TOKEN = os.environ.get("HERMES_PC_TOKEN", "").strip()
def require_auth(authorization: str | None = Header(default=None)) -> None:
"""Bearer-Token-Prüfung (konstante Zeit). 503 wenn der Executor ohne Token läuft (fail-closed),
401 bei fehlendem/falschem Token."""
if not AUTH_TOKEN:
raise HTTPException(
503,
"Executor ohne HERMES_PC_TOKEN gestartet — Steuer-Endpunkte sind aus Sicherheitsgründen "
"gesperrt. Setze die Env-Variable HERMES_PC_TOKEN (identisch zur Box) und starte neu.",
)
expected = f"Bearer {AUTH_TOKEN}"
if not authorization or not hmac.compare_digest(authorization, expected):
raise HTTPException(401, "Ungültiges oder fehlendes Bearer-Token.")
# ── Shell ────────────────────────────────────────────────────────────────── # ── Shell ──────────────────────────────────────────────────────────────────
@app.post("/shell") @app.post("/shell", dependencies=[Depends(require_auth)])
async def run_shell(req: dict): async def run_shell(req: dict):
cmd = req.get("command", "") cmd = req.get("command", "")
try: try:
@@ -44,7 +65,7 @@ async def run_shell(req: dict):
# ── Screen ───────────────────────────────────────────────────────────────── # ── Screen ─────────────────────────────────────────────────────────────────
@app.post("/screenshot") @app.post("/screenshot", dependencies=[Depends(require_auth)])
async def take_screenshot(): async def take_screenshot():
try: try:
import mss import mss
@@ -64,7 +85,7 @@ async def take_screenshot():
# ── Input ────────────────────────────────────────────────────────────────── # ── Input ──────────────────────────────────────────────────────────────────
@app.post("/type") @app.post("/type", dependencies=[Depends(require_auth)])
async def type_text(req: dict): async def type_text(req: dict):
text = req.get("text", "") text = req.get("text", "")
try: try:
@@ -75,7 +96,7 @@ async def type_text(req: dict):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.post("/key") @app.post("/key", dependencies=[Depends(require_auth)])
async def press_keys(req: dict): async def press_keys(req: dict):
keys = req.get("keys", "") keys = req.get("keys", "")
try: try:
@@ -89,7 +110,7 @@ async def press_keys(req: dict):
# ── Apps / Browser ───────────────────────────────────────────────────────── # ── Apps / Browser ─────────────────────────────────────────────────────────
@app.post("/open") @app.post("/open", dependencies=[Depends(require_auth)])
async def open_target(req: dict): async def open_target(req: dict):
target = req.get("target", "") target = req.get("target", "")
try: try:
@@ -102,7 +123,7 @@ async def open_target(req: dict):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.post("/search") @app.post("/search", dependencies=[Depends(require_auth)])
async def search_web(req: dict): async def search_web(req: dict):
query = req.get("query", "") query = req.get("query", "")
url = f"https://www.google.com/search?q={quote(query)}" url = f"https://www.google.com/search?q={quote(query)}"
@@ -145,5 +166,6 @@ def _ensure_streams() -> None:
if __name__ == "__main__": if __name__ == "__main__":
_ensure_streams() _ensure_streams()
my_ip = _get_local_ip() my_ip = _get_local_ip()
print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT}") auth_state = "Token AKTIV" if AUTH_TOKEN else "KEIN Token → Steuer-Endpunkte GESPERRT (fail-closed)"
uvicorn.run(app, host="0.0.0.0", port=MY_PORT) print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT} (bind {MY_HOST}) — {auth_state}")
uvicorn.run(app, host=MY_HOST, port=MY_PORT)
+5 -2
View File
@@ -14,6 +14,8 @@ import httpx
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
PC_URL = os.environ.get("PC_EXECUTOR_URL", "").rstrip("/") PC_URL = os.environ.get("PC_EXECUTOR_URL", "").rstrip("/")
# Shared Secret — muss identisch zum HERMES_PC_TOKEN des Executors auf dem Windows-PC sein.
PC_TOKEN = os.environ.get("PC_EXECUTOR_TOKEN", "").strip()
mcp = FastMCP("hermes-pc-control") mcp = FastMCP("hermes-pc-control")
@@ -21,12 +23,13 @@ mcp = FastMCP("hermes-pc-control")
def _pc(path: str, data: dict | None = None) -> dict: def _pc(path: str, data: dict | None = None) -> dict:
if not PC_URL: if not PC_URL:
return {"error": "PC_EXECUTOR_URL nicht gesetzt. Setze die Env-Variable mit der IP des Windows PCs."} return {"error": "PC_EXECUTOR_URL nicht gesetzt. Setze die Env-Variable mit der IP des Windows PCs."}
headers = {"Authorization": f"Bearer {PC_TOKEN}"} if PC_TOKEN else {}
try: try:
with httpx.Client(timeout=90) as c: with httpx.Client(timeout=90) as c:
if data is None: if data is None:
r = c.get(f"{PC_URL}{path}") r = c.get(f"{PC_URL}{path}", headers=headers)
else: else:
r = c.post(f"{PC_URL}{path}", json=data) r = c.post(f"{PC_URL}{path}", json=data, headers=headers)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.ConnectError: except httpx.ConnectError: