Skip to content

Commit 51e7076

Browse files
security: address PR review - timing-safe comparison, thread lock, specific exceptions, graceful termination
1 parent 5bf69a2 commit 51e7076

2 files changed

Lines changed: 40 additions & 22 deletions

File tree

fri/server/main.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66
from subprocess import call,check_output
77
from pathlib import Path
88
import json
9+
import logging
910
import platform
1011
import re
12+
import secrets
13+
import threading
1114
from flask_cors import CORS, cross_origin
1215

16+
logger = logging.getLogger(__name__)
17+
1318
# Input validation pattern for safe names (alphanumeric, dash, underscore, slash, dot, space)
1419
SAFE_INPUT_PATTERN = re.compile(r'^[a-zA-Z0-9_\-/. ]+$')
1520
# Pattern for filenames - no path separators or .. allowed
@@ -91,12 +96,13 @@ def require_api_key():
9196
"""Require a valid API key via X-API-KEY header."""
9297
if not API_KEY:
9398
abort(500, description="Server not configured with API key")
94-
provided = request.headers.get("X-API-KEY")
95-
if provided != API_KEY:
99+
provided = request.headers.get("X-API-KEY") or ""
100+
if not secrets.compare_digest(provided, API_KEY):
96101
abort(403, description="Unauthorized")
97102

98103
# Track single Jupyter process to prevent multiple concurrent launches
99104
jupyter_process = None
105+
jupyter_lock = threading.Lock()
100106

101107

102108
app = Flask(__name__)
@@ -554,20 +560,22 @@ def openJupyter():
554560

555561
require_api_key()
556562

557-
if jupyter_process and jupyter_process.poll() is None:
558-
return jsonify({"message": "Jupyter already running"}), 409
563+
with jupyter_lock:
564+
if jupyter_process and jupyter_process.poll() is None:
565+
return jsonify({"message": "Jupyter already running"}), 409
559566

560-
try:
561-
jupyter_process = subprocess.Popen(
562-
['jupyter', 'lab', '--no-browser'],
563-
shell=False,
564-
cwd=concore_path,
565-
stdout=subprocess.DEVNULL,
566-
stderr=subprocess.DEVNULL
567-
)
568-
return jsonify({"message": "Jupyter Lab started"}), 200
569-
except Exception:
570-
return jsonify({"error": "Failed to start Jupyter"}), 500
567+
try:
568+
jupyter_process = subprocess.Popen(
569+
['jupyter', 'lab', '--no-browser'],
570+
shell=False,
571+
cwd=concore_path,
572+
stdout=subprocess.DEVNULL,
573+
stderr=subprocess.DEVNULL
574+
)
575+
return jsonify({"message": "Jupyter Lab started"}), 200
576+
except (FileNotFoundError, PermissionError, OSError) as e:
577+
logger.error("Failed to start Jupyter: %s", e)
578+
return jsonify({"error": "Failed to start Jupyter"}), 500
571579

572580

573581
@app.route('/stopJupyter/', methods=['POST'])
@@ -576,13 +584,22 @@ def stopJupyter():
576584

577585
require_api_key()
578586

579-
if not jupyter_process or jupyter_process.poll() is not None:
580-
return jsonify({"message": "No running Jupyter instance"}), 404
581-
582-
jupyter_process.terminate()
583-
jupyter_process = None
587+
with jupyter_lock:
588+
if not jupyter_process or jupyter_process.poll() is not None:
589+
return jsonify({"message": "No running Jupyter instance"}), 404
584590

585-
return jsonify({"message": "Jupyter stopped"}), 200
591+
try:
592+
jupyter_process.terminate()
593+
# Wait for Jupyter to terminate gracefully
594+
jupyter_process.wait(timeout=5)
595+
except subprocess.TimeoutExpired:
596+
# Force kill if it did not exit in time
597+
jupyter_process.kill()
598+
jupyter_process.wait(timeout=5)
599+
finally:
600+
jupyter_process = None
601+
602+
return jsonify({"message": "Jupyter stopped"}), 200
586603

587604

588605
if __name__ == "__main__":

tests/test_openjupyter_security.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def test_duplicate_launch_returns_409(self, mock_popen, client):
109109
data = resp2.get_json()
110110
assert data["message"] == "Jupyter already running"
111111

112-
@patch("fri.server.main.subprocess.Popen", side_effect=Exception("fail"))
112+
@patch("fri.server.main.subprocess.Popen", side_effect=OSError("fail"))
113113
def test_popen_failure_returns_500(self, mock_popen, client):
114114
"""If Popen raises, return 500."""
115115
resp = client.post(
@@ -153,3 +153,4 @@ def test_stop_running_process_returns_200(self, mock_popen, client):
153153
data = resp.get_json()
154154
assert data["message"] == "Jupyter stopped"
155155
mock_proc.terminate.assert_called_once()
156+
mock_proc.wait.assert_called()

0 commit comments

Comments
 (0)