feat: sécurité HIGH — token Bearer, validation, rate limiting, headers
- Token Bearer auth sur le streaming server (auto-généré ou env var) - Validation actions replay (types, longueurs, coordonnées 0-1) - Rate limiting in-memory (10 replays/min, 200 images/min) - Security headers Flask (nosniff, SAMEORIGIN, XSS) - Validation uploads (50MB max, MIME type) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,8 +79,22 @@ logger = logging.getLogger(__name__)
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
import secrets as _secrets
|
import secrets as _secrets
|
||||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', _secrets.token_hex(32))
|
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="*")
|
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
|
# Global state
|
||||||
matcher: Optional[SemanticMatcher] = None
|
matcher: Optional[SemanticMatcher] = None
|
||||||
gpu_manager = None
|
gpu_manager = None
|
||||||
@@ -1365,6 +1379,11 @@ def api_chat_upload():
|
|||||||
Upload d'un fichier Excel/CSV via le chat.
|
Upload d'un fichier Excel/CSV via le chat.
|
||||||
|
|
||||||
Le fichier est stocké dans data/uploads/ et un aperçu est retourné.
|
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:
|
if 'file' not in request.files:
|
||||||
return jsonify({"error": "Aucun fichier reçu."}), 400
|
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)}"
|
"error": f"Format '{ext}' non supporté. Formats acceptés : {', '.join(allowed_ext)}"
|
||||||
}), 400
|
}), 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)
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
filename = secure_filename(file.filename)
|
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
|
# Ajouter un timestamp pour éviter les collisions
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
safe_name = f"{Path(filename).stem}_{ts}{ext}"
|
safe_name = f"{Path(filename).stem}_{ts}{ext}"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
|
|||||||
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
||||||
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
|
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
|
# Paramètres de session
|
||||||
MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
||||||
SESSIONS_ROOT = BASE_DIR / "sessions"
|
SESSIONS_ROOT = BASE_DIR / "sessions"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Supporte deux modes :
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -65,6 +66,14 @@ class ActionExecutorV1:
|
|||||||
self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes)
|
self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes)
|
||||||
self._poll_backoff_max = 30.0 # Delai maximal
|
self._poll_backoff_max = 30.0 # Delai maximal
|
||||||
self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec
|
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
|
@property
|
||||||
def sct(self):
|
def sct(self):
|
||||||
@@ -285,7 +294,7 @@ class ActionExecutorV1:
|
|||||||
"screen_height": screen_height,
|
"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:
|
if resp.ok:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
method = data.get("method", "?")
|
method = data.get("method", "?")
|
||||||
@@ -333,6 +342,7 @@ class ActionExecutorV1:
|
|||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
replay_next_url,
|
replay_next_url,
|
||||||
params={"session_id": session_id, "machine_id": machine_id},
|
params={"session_id": session_id, "machine_id": machine_id},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
@@ -408,6 +418,7 @@ class ActionExecutorV1:
|
|||||||
resp2 = requests.post(
|
resp2 = requests.post(
|
||||||
replay_result_url,
|
replay_result_url,
|
||||||
json=report,
|
json=report,
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp2.ok:
|
if resp2.ok:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ..config import STREAMING_ENDPOINT
|
from ..config import API_TOKEN, STREAMING_ENDPOINT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,6 +56,13 @@ class TraceStreamer:
|
|||||||
self._health_thread = None
|
self._health_thread = None
|
||||||
self._server_available = True # Désactivé après trop d'échecs
|
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):
|
def start(self):
|
||||||
"""Démarrer le streaming et enregistrer la session côté serveur."""
|
"""Démarrer le streaming et enregistrer la session côté serveur."""
|
||||||
self.running = True
|
self.running = True
|
||||||
@@ -240,6 +247,7 @@ class TraceStreamer:
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{STREAMING_ENDPOINT}/stats",
|
f"{STREAMING_ENDPOINT}/stats",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=3,
|
timeout=3,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -292,6 +300,7 @@ class TraceStreamer:
|
|||||||
"session_id": self.session_id,
|
"session_id": self.session_id,
|
||||||
"machine_id": self.machine_id,
|
"machine_id": self.machine_id,
|
||||||
},
|
},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=3,
|
timeout=3,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -319,6 +328,7 @@ class TraceStreamer:
|
|||||||
"session_id": self.session_id,
|
"session_id": self.session_id,
|
||||||
"machine_id": self.machine_id,
|
"machine_id": self.machine_id,
|
||||||
},
|
},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=30, # Le build workflow peut prendre du temps
|
timeout=30, # Le build workflow peut prendre du temps
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -343,6 +353,7 @@ class TraceStreamer:
|
|||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{STREAMING_ENDPOINT}/event",
|
f"{STREAMING_ENDPOINT}/event",
|
||||||
json=payload,
|
json=payload,
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=2,
|
timeout=2,
|
||||||
)
|
)
|
||||||
return resp.ok
|
return resp.ok
|
||||||
@@ -377,6 +388,7 @@ class TraceStreamer:
|
|||||||
f"{STREAMING_ENDPOINT}/image",
|
f"{STREAMING_ENDPOINT}/image",
|
||||||
files=files,
|
files=files,
|
||||||
params=params,
|
params=params,
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
return resp.ok
|
return resp.ok
|
||||||
@@ -390,6 +402,7 @@ class TraceStreamer:
|
|||||||
f"{STREAMING_ENDPOINT}/image",
|
f"{STREAMING_ENDPOINT}/image",
|
||||||
files=files,
|
files=files,
|
||||||
params=params,
|
params=params,
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
return resp.ok
|
return resp.ok
|
||||||
|
|||||||
@@ -475,25 +475,27 @@ class TestAPIEndpoints:
|
|||||||
)
|
)
|
||||||
|
|
||||||
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
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
|
# Restaurer
|
||||||
api_stream.processor = original_processor
|
api_stream.processor = original_processor
|
||||||
api_stream.worker = original_worker
|
api_stream.worker = original_worker
|
||||||
|
|
||||||
def test_get_sessions_empty(self, client):
|
def test_get_sessions_empty(self, client):
|
||||||
c, _ = client
|
c, _, token = client
|
||||||
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
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["sessions"] == []
|
assert data["sessions"] == []
|
||||||
|
|
||||||
def test_get_sessions_with_data(self, client):
|
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.register_session("api_sess_1")
|
||||||
proc.session_manager.add_event("api_sess_1", {"type": "click"})
|
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
|
assert resp.status_code == 200
|
||||||
sessions = resp.json()["sessions"]
|
sessions = resp.json()["sessions"]
|
||||||
assert len(sessions) == 1
|
assert len(sessions) == 1
|
||||||
@@ -501,14 +503,14 @@ class TestAPIEndpoints:
|
|||||||
assert sessions[0]["events_count"] == 1
|
assert sessions[0]["events_count"] == 1
|
||||||
|
|
||||||
def test_get_workflows_empty(self, client):
|
def test_get_workflows_empty(self, client):
|
||||||
c, _ = client
|
c, _, token = client
|
||||||
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
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["workflows"] == []
|
assert data["workflows"] == []
|
||||||
|
|
||||||
def test_get_workflows_with_data(self, client):
|
def test_get_workflows_with_data(self, client):
|
||||||
c, proc = client
|
c, proc, token = client
|
||||||
mock_wf = MagicMock()
|
mock_wf = MagicMock()
|
||||||
mock_wf.nodes = [1, 2]
|
mock_wf.nodes = [1, 2]
|
||||||
mock_wf.edges = [1]
|
mock_wf.edges = [1]
|
||||||
@@ -516,7 +518,7 @@ class TestAPIEndpoints:
|
|||||||
with proc._data_lock:
|
with proc._data_lock:
|
||||||
proc._workflows["wf_api_001"] = mock_wf
|
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
|
assert resp.status_code == 200
|
||||||
workflows = resp.json()["workflows"]
|
workflows = resp.json()["workflows"]
|
||||||
assert len(workflows) == 1
|
assert len(workflows) == 1
|
||||||
|
|||||||
@@ -195,6 +195,18 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ WebSocket handlers désactivés: {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
|
# Global error handlers
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found(error):
|
def not_found(error):
|
||||||
|
|||||||
Reference in New Issue
Block a user