diff --git a/agent_chat/app.py b/agent_chat/app.py index 7a1ffbe84..3c36ffa96 100644 --- a/agent_chat/app.py +++ b/agent_chat/app.py @@ -79,8 +79,22 @@ logger = logging.getLogger(__name__) app = Flask(__name__) import secrets as _secrets app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', _secrets.token_hex(32)) +app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50 MB max upload (sécurité HIGH) socketio = SocketIO(app, cors_allowed_origins="*") + +# ============================================================ +# Headers de sécurité (sécurité HIGH) +# ============================================================ +@app.after_request +def set_security_headers(response): + """Ajouter les headers de sécurité à toutes les réponses.""" + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['X-XSS-Protection'] = '1; mode=block' + return response + + # Global state matcher: Optional[SemanticMatcher] = None gpu_manager = None @@ -1365,6 +1379,11 @@ def api_chat_upload(): Upload d'un fichier Excel/CSV via le chat. Le fichier est stocké dans data/uploads/ et un aperçu est retourné. + Validations de sécurité (HIGH) : + - Taille max : 50 MB (via MAX_CONTENT_LENGTH Flask) + - Extension autorisée : .xlsx, .xls, .csv + - Type MIME vérifié (pas juste l'extension) + - Nom de fichier assaini via secure_filename """ if 'file' not in request.files: return jsonify({"error": "Aucun fichier reçu."}), 400 @@ -1381,9 +1400,32 @@ def api_chat_upload(): "error": f"Format '{ext}' non supporté. Formats acceptés : {', '.join(allowed_ext)}" }), 400 - # Sauvegarder le fichier + # Validation du type MIME (sécurité HIGH — pas juste l'extension) + _ALLOWED_MIMES = { + '.xlsx': {'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/octet-stream'}, + '.xls': {'application/vnd.ms-excel', 'application/octet-stream'}, + '.csv': {'text/csv', 'text/plain', 'application/csv', 'application/octet-stream'}, + } + file_mime = file.content_type or '' + allowed_mimes = _ALLOWED_MIMES.get(ext, set()) + if file_mime and file_mime not in allowed_mimes: + logger.warning( + f"Upload rejeté : MIME '{file_mime}' invalide pour extension '{ext}' " + f"(fichier: {file.filename})" + ) + return jsonify({ + "error": f"Type MIME '{file_mime}' invalide pour un fichier {ext}. " + f"Types attendus : {', '.join(allowed_mimes)}" + }), 400 + + # Assainir le nom de fichier (sécurité HIGH — empêcher path traversal) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) filename = secure_filename(file.filename) + if not filename: + # secure_filename peut retourner une chaîne vide pour des noms exotiques + filename = f"upload_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" + # Ajouter un timestamp pour éviter les collisions ts = datetime.now().strftime("%Y%m%d_%H%M%S") safe_name = f"{Path(filename).stem}_{ts}{ext}" diff --git a/agent_v0/agent_v1/config.py b/agent_v0/agent_v1/config.py index dde33226b..c67a8a858 100644 --- a/agent_v0/agent_v1/config.py +++ b/agent_v0/agent_v1/config.py @@ -25,6 +25,10 @@ SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1") UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload" STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" +# Token d'authentification API (doit correspondre au token du serveur) +# Configurable via variable d'environnement RPA_API_TOKEN +API_TOKEN = os.environ.get("RPA_API_TOKEN", "") + # Paramètres de session MAX_SESSION_DURATION_S = 60 * 60 # 1 heure SESSIONS_ROOT = BASE_DIR / "sessions" diff --git a/agent_v0/agent_v1/core/executor.py b/agent_v0/agent_v1/core/executor.py index 0645a6729..2855c2f66 100644 --- a/agent_v0/agent_v1/core/executor.py +++ b/agent_v0/agent_v1/core/executor.py @@ -10,6 +10,7 @@ Supporte deux modes : import base64 import io +import os import time import logging @@ -65,6 +66,14 @@ class ActionExecutorV1: self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes) self._poll_backoff_max = 30.0 # Delai maximal self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec + # Token d'authentification API + self._api_token = os.environ.get("RPA_API_TOKEN", "") + + def _auth_headers(self) -> dict: + """Headers d'authentification Bearer pour les requetes au serveur.""" + if self._api_token: + return {"Authorization": f"Bearer {self._api_token}"} + return {} @property def sct(self): @@ -285,7 +294,7 @@ class ActionExecutorV1: "screen_height": screen_height, } - resp = requests.post(resolve_url, json=payload, timeout=60) + resp = requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=60) if resp.ok: data = resp.json() method = data.get("method", "?") @@ -333,6 +342,7 @@ class ActionExecutorV1: resp = requests.get( replay_next_url, params={"session_id": session_id, "machine_id": machine_id}, + headers=self._auth_headers(), timeout=5, ) if not resp.ok: @@ -408,6 +418,7 @@ class ActionExecutorV1: resp2 = requests.post( replay_result_url, json=report, + headers=self._auth_headers(), timeout=10, ) if resp2.ok: diff --git a/agent_v0/agent_v1/network/streamer.py b/agent_v0/agent_v1/network/streamer.py index 65dd50f97..10f59bc55 100644 --- a/agent_v0/agent_v1/network/streamer.py +++ b/agent_v0/agent_v1/network/streamer.py @@ -25,7 +25,7 @@ import time import requests from PIL import Image -from ..config import STREAMING_ENDPOINT +from ..config import API_TOKEN, STREAMING_ENDPOINT logger = logging.getLogger(__name__) @@ -56,6 +56,13 @@ class TraceStreamer: self._health_thread = None self._server_available = True # Désactivé après trop d'échecs + @staticmethod + def _auth_headers() -> dict: + """Headers d'authentification Bearer pour les requêtes API.""" + if API_TOKEN: + return {"Authorization": f"Bearer {API_TOKEN}"} + return {} + def start(self): """Démarrer le streaming et enregistrer la session côté serveur.""" self.running = True @@ -240,6 +247,7 @@ class TraceStreamer: try: resp = requests.get( f"{STREAMING_ENDPOINT}/stats", + headers=self._auth_headers(), timeout=3, ) if resp.ok: @@ -292,6 +300,7 @@ class TraceStreamer: "session_id": self.session_id, "machine_id": self.machine_id, }, + headers=self._auth_headers(), timeout=3, ) if resp.ok: @@ -319,6 +328,7 @@ class TraceStreamer: "session_id": self.session_id, "machine_id": self.machine_id, }, + headers=self._auth_headers(), timeout=30, # Le build workflow peut prendre du temps ) if resp.ok: @@ -343,6 +353,7 @@ class TraceStreamer: resp = requests.post( f"{STREAMING_ENDPOINT}/event", json=payload, + headers=self._auth_headers(), timeout=2, ) return resp.ok @@ -377,6 +388,7 @@ class TraceStreamer: f"{STREAMING_ENDPOINT}/image", files=files, params=params, + headers=self._auth_headers(), timeout=5, ) return resp.ok @@ -390,6 +402,7 @@ class TraceStreamer: f"{STREAMING_ENDPOINT}/image", files=files, params=params, + headers=self._auth_headers(), timeout=5, ) return resp.ok diff --git a/tests/integration/test_stream_processor.py b/tests/integration/test_stream_processor.py index a4548d4a2..4180c71ff 100644 --- a/tests/integration/test_stream_processor.py +++ b/tests/integration/test_stream_processor.py @@ -475,25 +475,27 @@ class TestAPIEndpoints: ) client = TestClient(api_stream.app, raise_server_exceptions=False) - yield client, test_processor + # Récupérer le token API pour les requêtes authentifiées + token = api_stream.API_TOKEN + yield client, test_processor, token # Restaurer api_stream.processor = original_processor api_stream.worker = original_worker def test_get_sessions_empty(self, client): - c, _ = client - resp = c.get("/api/v1/traces/stream/sessions") + c, _, token = client + resp = c.get("/api/v1/traces/stream/sessions", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["sessions"] == [] def test_get_sessions_with_data(self, client): - c, proc = client + c, proc, token = client proc.session_manager.register_session("api_sess_1") proc.session_manager.add_event("api_sess_1", {"type": "click"}) - resp = c.get("/api/v1/traces/stream/sessions") + resp = c.get("/api/v1/traces/stream/sessions", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 sessions = resp.json()["sessions"] assert len(sessions) == 1 @@ -501,14 +503,14 @@ class TestAPIEndpoints: assert sessions[0]["events_count"] == 1 def test_get_workflows_empty(self, client): - c, _ = client - resp = c.get("/api/v1/traces/stream/workflows") + c, _, token = client + resp = c.get("/api/v1/traces/stream/workflows", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 data = resp.json() assert data["workflows"] == [] def test_get_workflows_with_data(self, client): - c, proc = client + c, proc, token = client mock_wf = MagicMock() mock_wf.nodes = [1, 2] mock_wf.edges = [1] @@ -516,7 +518,7 @@ class TestAPIEndpoints: with proc._data_lock: proc._workflows["wf_api_001"] = mock_wf - resp = c.get("/api/v1/traces/stream/workflows") + resp = c.get("/api/v1/traces/stream/workflows", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 workflows = resp.json()["workflows"] assert len(workflows) == 1 diff --git a/visual_workflow_builder/backend/app.py b/visual_workflow_builder/backend/app.py index c931097ec..d4d275acf 100644 --- a/visual_workflow_builder/backend/app.py +++ b/visual_workflow_builder/backend/app.py @@ -195,6 +195,18 @@ try: except Exception as e: print(f"⚠️ WebSocket handlers désactivés: {e}") +# ============================================================ +# Headers de sécurité (sécurité HIGH) +# ============================================================ +@app.after_request +def set_security_headers(response): + """Ajouter les headers de sécurité à toutes les réponses.""" + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['X-XSS-Protection'] = '1; mode=block' + return response + + # Global error handlers @app.errorhandler(404) def not_found(error):