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}")

View File

@@ -136,8 +136,18 @@ def capture_windows():
agent_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006'))
agent_url = f'http://{agent_host}:{agent_port}/capture'
# Auth : l'agent exige un Bearer token (meme RPA_API_TOKEN que le streaming)
api_token = os.environ.get('RPA_API_TOKEN', '')
headers = {'Authorization': f'Bearer {api_token}'} if api_token else {}
try:
resp = http_client.get(agent_url, timeout=10)
resp = http_client.get(agent_url, headers=headers, timeout=10)
if resp.status_code == 401:
return jsonify({
'error': 'Agent Windows : authentification refusee',
'hint': 'Verifiez que RPA_API_TOKEN est defini et identique '
'cote backend VWB et cote agent Windows.',
}), 401
if resp.ok:
return jsonify(resp.json())
return jsonify({

View File

@@ -359,7 +359,7 @@ def _execute_db_foreach(
# 3. Pour chaque ligne, injecter et exécuter
iteration_results = []
model = executor_kwargs.get("model", "qwen3-vl:8b")
model = executor_kwargs.get("model", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b")))
ollama_endpoint = executor_kwargs.get("ollama_endpoint", "http://localhost:11434")
timeout = executor_kwargs.get("timeout", 300)
@@ -514,7 +514,7 @@ def execute_dag(workflow_id: str):
# Paramètres optionnels
timeout = data.get("timeout", 300)
model = data.get("model", "qwen3-vl:8b")
model = data.get("model", os.environ.get("RPA_VLM_MODEL", os.environ.get("VLM_MODEL", "gemma4:e4b")))
ollama_endpoint = data.get("ollama_endpoint", "http://localhost:11434")
executor_kwargs = {
@@ -1000,21 +1000,34 @@ def execute_windows():
file_actions = [a for a in data['actions'] if a.get('type', '') in _FILE_ACTION_TYPES]
if file_actions:
# Exécuter les actions fichiers via l'agent Windows
# Auth : Bearer token obligatoire (capture_server.py exige RPA_API_TOKEN)
_agent_host = os.environ.get('RPA_WINDOWS_AGENT_HOST', '192.168.1.11')
_agent_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006'))
_agent_url = f'http://{_agent_host}:{_agent_port}/file-action'
_api_token = os.environ.get('RPA_API_TOKEN', '')
_file_headers = {'Authorization': f'Bearer {_api_token}'} if _api_token else {}
file_results = []
for fa in file_actions:
try:
fa_resp = req.post(
'http://192.168.1.11:5006/file-action',
_agent_url,
json={
'action': fa['type'],
'params': fa.get('parameters', {}),
},
headers=_file_headers,
timeout=30,
)
file_results.append(fa_resp.json())
if fa_resp.status_code == 401:
file_results.append({
'error': "Agent Windows : auth refusee (verifier RPA_API_TOKEN)",
})
else:
file_results.append(fa_resp.json())
except req.ConnectionError:
file_results.append({
'error': "Agent Windows (port 5006) non disponible pour l'action fichier"
'error': f"Agent Windows ({_agent_host}:{_agent_port}) non disponible pour l'action fichier"
})
except Exception as e:
file_results.append({'error': str(e)})