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:
@@ -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}")
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
Reference in New Issue
Block a user