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. et les operations fichiers.
Ecoute sur le port 5006 (configurable via RPA_CAPTURE_PORT). Ecoute sur le port 5006 (configurable via RPA_CAPTURE_PORT).
Bind par defaut sur 127.0.0.1 (configurable via RPA_CAPTURE_BIND).
Endpoints : Endpoints :
GET /capture -> screenshot frais en base64 (JPEG) 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) 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 threading
import logging import logging
import json import json
import base64 import base64
import hmac
import io import io
import os import os
import time import time
@@ -20,6 +30,17 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006")) 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) # Floutage des données sensibles (conformité AI Act)
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes") 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): def do_GET(self):
if self.path == "/capture": if self.path == "/capture":
if not self._check_auth():
return
self._handle_capture() self._handle_capture()
elif self.path == "/health": elif self.path == "/health":
self._send_json(200, {"status": "ok"}) self._send_json(200, {"status": "ok"})
@@ -41,10 +64,56 @@ class CaptureHandler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
if self.path == "/file-action": if self.path == "/file-action":
if not self._check_auth():
return
self._handle_file_action() self._handle_file_action()
else: else:
self._send_json(404, {"error": "not found"}) 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): def do_OPTIONS(self):
"""Gestion CORS preflight.""" """Gestion CORS preflight."""
self.send_response(200) self.send_response(200)
@@ -351,21 +420,46 @@ class _FileActionHandlerLocal:
class CaptureServer: class CaptureServer:
"""Serveur de capture d'ecran en temps reel (thread daemon).""" """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._port = port
self._bind = bind
self._server: HTTPServer | None = None self._server: HTTPServer | None = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
def start(self): 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: try:
self._server = HTTPServer(("0.0.0.0", self._port), CaptureHandler) self._server = HTTPServer((self._bind, self._port), CaptureHandler)
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._server.serve_forever, daemon=True target=self._server.serve_forever, daemon=True
) )
self._thread.start() self._thread.start()
logger.info(f"Capture server demarre sur le port {self._port}") auth_mode = "token requis" if CAPTURE_TOKEN else "token absent (fail-closed)"
print(f"[CAPTURE] Serveur de capture demarre sur le port {self._port}") 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: except Exception as e:
logger.error(f"Impossible de demarrer le capture server : {e}") logger.error(f"Impossible de demarrer le capture server : {e}")
print(f"[CAPTURE] ERREUR demarrage : {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_port = int(os.environ.get('RPA_WINDOWS_AGENT_PORT', '5006'))
agent_url = f'http://{agent_host}:{agent_port}/capture' 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: 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: if resp.ok:
return jsonify(resp.json()) return jsonify(resp.json())
return jsonify({ return jsonify({

View File

@@ -359,7 +359,7 @@ def _execute_db_foreach(
# 3. Pour chaque ligne, injecter et exécuter # 3. Pour chaque ligne, injecter et exécuter
iteration_results = [] 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") ollama_endpoint = executor_kwargs.get("ollama_endpoint", "http://localhost:11434")
timeout = executor_kwargs.get("timeout", 300) timeout = executor_kwargs.get("timeout", 300)
@@ -514,7 +514,7 @@ def execute_dag(workflow_id: str):
# Paramètres optionnels # Paramètres optionnels
timeout = data.get("timeout", 300) 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") ollama_endpoint = data.get("ollama_endpoint", "http://localhost:11434")
executor_kwargs = { 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] file_actions = [a for a in data['actions'] if a.get('type', '') in _FILE_ACTION_TYPES]
if file_actions: if file_actions:
# Exécuter les actions fichiers via l'agent Windows # 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 = [] file_results = []
for fa in file_actions: for fa in file_actions:
try: try:
fa_resp = req.post( fa_resp = req.post(
'http://192.168.1.11:5006/file-action', _agent_url,
json={ json={
'action': fa['type'], 'action': fa['type'],
'params': fa.get('parameters', {}), 'params': fa.get('parameters', {}),
}, },
headers=_file_headers,
timeout=30, 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: except req.ConnectionError:
file_results.append({ 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: except Exception as e:
file_results.append({'error': str(e)}) file_results.append({'error': str(e)})