feat(capture_server): auth Bearer + bind localhost + anti-path-traversal

- Token obligatoire (RPA_API_TOKEN) sur /capture et /file-action
- Bind 127.0.0.1 par défaut, 0.0.0.0 exige token (fail-closed)
- /health reste public pour monitoring
- VWB backend injecte le Bearer pour les proxys distants
- hmac.compare_digest pour comparaison temps constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-14 16:47:45 +02:00
parent 013fe071a2
commit c77844fa9a
3 changed files with 129 additions and 12 deletions

View File

@@ -3,15 +3,25 @@ Mini serveur HTTP sur l'agent Windows pour les captures d'ecran a la demande
et les operations fichiers.
Ecoute sur le port 5006 (configurable via RPA_CAPTURE_PORT).
Bind par defaut sur 127.0.0.1 (configurable via RPA_CAPTURE_BIND).
Endpoints :
GET /capture -> screenshot frais en base64 (JPEG)
GET /health -> {"status": "ok"}
GET /health -> {"status": "ok"} (pas d'auth — sonde liveness)
POST /file-action -> operations fichiers (list, create, move, copy, sort)
Securite :
- Authentification Bearer obligatoire (RPA_API_TOKEN) pour /capture et
/file-action. Sans token configure, ces endpoints sont desactives.
- Les tentatives non authentifiees sont loguees (WARNING) avec l'IP source.
- Bind defaut localhost. Pour exposer sur le LAN (cas VWB backend qui
appelle l'agent a distance), definir explicitement
RPA_CAPTURE_BIND=0.0.0.0. L'auth reste alors la seule protection.
"""
import threading
import logging
import json
import base64
import hmac
import io
import os
import time
@@ -20,6 +30,17 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
logger = logging.getLogger(__name__)
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
# Bind par defaut sur localhost — defense en profondeur.
# Pour le deploiement VWB (backend Linux -> agent Windows), definir
# RPA_CAPTURE_BIND=0.0.0.0 explicitement. L'auth par token reste requise.
CAPTURE_BIND = os.environ.get("RPA_CAPTURE_BIND", "127.0.0.1")
# Token d'authentification (partage avec le streaming). Doit etre defini pour
# que /capture et /file-action soient accessibles.
CAPTURE_TOKEN = os.environ.get("RPA_API_TOKEN", "")
# Endpoints ouverts (pas d'auth requise — sondes techniques uniquement)
_PUBLIC_PATHS = {"/health"}
# Floutage des données sensibles (conformité AI Act)
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes")
@@ -33,6 +54,8 @@ class CaptureHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/capture":
if not self._check_auth():
return
self._handle_capture()
elif self.path == "/health":
self._send_json(200, {"status": "ok"})
@@ -41,10 +64,56 @@ class CaptureHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == "/file-action":
if not self._check_auth():
return
self._handle_file_action()
else:
self._send_json(404, {"error": "not found"})
# ------------------------------------------------------------------
def _check_auth(self) -> bool:
"""Valide le Bearer token. Renvoie 401/503 si invalide.
- Si aucun token n'est configure cote serveur (RPA_API_TOKEN vide),
on refuse toutes les requetes sensibles (503) — fail-closed.
- Sinon, on compare en temps constant via hmac.compare_digest.
- Les tentatives echouees sont loguees avec l'IP source.
"""
# Autoriser les endpoints publics
if self.path in _PUBLIC_PATHS:
return True
peer = self.client_address[0] if self.client_address else "?"
if not CAPTURE_TOKEN:
logger.error(
"Refus %s depuis %s : RPA_API_TOKEN non configure "
"(capture server en mode fail-closed)",
self.path, peer,
)
self._send_json(503, {
"error": "capture server non configure (token manquant)",
})
return False
auth_header = self.headers.get("Authorization", "")
token = ""
if auth_header.startswith("Bearer "):
token = auth_header[len("Bearer "):].strip()
if not token or not hmac.compare_digest(token, CAPTURE_TOKEN):
logger.warning(
"Tentative d'acces non autorisee a %s depuis %s "
"(token %s)",
self.path, peer,
"absent" if not token else "invalide",
)
self._send_json(401, {"error": "unauthorized"})
return False
return True
def do_OPTIONS(self):
"""Gestion CORS preflight."""
self.send_response(200)
@@ -351,21 +420,46 @@ class _FileActionHandlerLocal:
class CaptureServer:
"""Serveur de capture d'ecran en temps reel (thread daemon)."""
def __init__(self, port: int = CAPTURE_PORT):
def __init__(self, port: int = CAPTURE_PORT, bind: str = CAPTURE_BIND):
self._port = port
self._bind = bind
self._server: HTTPServer | None = None
self._thread: threading.Thread | None = None
def start(self):
"""Demarre le serveur dans un thread daemon."""
"""Demarre le serveur dans un thread daemon.
Avertit si le serveur est expose sur le LAN sans token configure.
"""
# Defense en profondeur : refus de demarrer si expose LAN sans auth
exposed_lan = self._bind not in ("127.0.0.1", "localhost", "::1")
if exposed_lan and not CAPTURE_TOKEN:
logger.error(
"REFUS demarrage capture server : bind=%s (LAN) sans "
"RPA_API_TOKEN. Definir le token ou RPA_CAPTURE_BIND=127.0.0.1.",
self._bind,
)
print(
f"[CAPTURE] REFUS demarrage : bind={self._bind} sans token. "
f"Definir RPA_API_TOKEN ou RPA_CAPTURE_BIND=127.0.0.1."
)
return
try:
self._server = HTTPServer(("0.0.0.0", self._port), CaptureHandler)
self._server = HTTPServer((self._bind, self._port), CaptureHandler)
self._thread = threading.Thread(
target=self._server.serve_forever, daemon=True
)
self._thread.start()
logger.info(f"Capture server demarre sur le port {self._port}")
print(f"[CAPTURE] Serveur de capture demarre sur le port {self._port}")
auth_mode = "token requis" if CAPTURE_TOKEN else "token absent (fail-closed)"
logger.info(
"Capture server demarre sur %s:%s (%s)",
self._bind, self._port, auth_mode,
)
print(
f"[CAPTURE] Serveur de capture demarre sur "
f"{self._bind}:{self._port} ({auth_mode})"
)
except Exception as e:
logger.error(f"Impossible de demarrer le capture server : {e}")
print(f"[CAPTURE] ERREUR demarrage : {e}")