Skip to content

Commit 6b7f645

Browse files
authored
Merge pull request #409 from GaneshPatil7517/security/protect-openjupyter-endpoint
security: protect /openJupyter endpoint with API key and process control (fixes #361)
2 parents 1fccbbb + 51e7076 commit 6b7f645

2 files changed

Lines changed: 220 additions & 9 deletions

File tree

fri/server/main.py

Lines changed: 64 additions & 9 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
@@ -84,6 +89,21 @@ def get_error_output(e):
8489
cur_path = os.path.dirname(os.path.abspath(__file__))
8590
concore_path = os.path.abspath(os.path.join(cur_path, '../../'))
8691

92+
# API key authentication for sensitive endpoints
93+
API_KEY = os.environ.get("CONCORE_API_KEY")
94+
95+
def require_api_key():
96+
"""Require a valid API key via X-API-KEY header."""
97+
if not API_KEY:
98+
abort(500, description="Server not configured with API key")
99+
provided = request.headers.get("X-API-KEY") or ""
100+
if not secrets.compare_digest(provided, API_KEY):
101+
abort(403, description="Unauthorized")
102+
103+
# Track single Jupyter process to prevent multiple concurrent launches
104+
jupyter_process = None
105+
jupyter_lock = threading.Lock()
106+
87107

88108
app = Flask(__name__)
89109
secret_key = os.environ.get("FLASK_SECRET_KEY")
@@ -543,15 +563,50 @@ def getFilesList(dir):
543563

544564
@app.route('/openJupyter/', methods=['POST'])
545565
def openJupyter():
546-
proc = subprocess.Popen(['jupyter', 'lab'], shell=False, stdout=subprocess.PIPE, cwd=concore_path)
547-
if proc.poll() is None:
548-
resp = jsonify({'message': 'Successfuly opened Jupyter'})
549-
resp.status_code = 308
550-
return resp
551-
else:
552-
resp = jsonify({'message': 'There is an Error'})
553-
resp.status_code = 500
554-
return resp
566+
global jupyter_process
567+
568+
require_api_key()
569+
570+
with jupyter_lock:
571+
if jupyter_process and jupyter_process.poll() is None:
572+
return jsonify({"message": "Jupyter already running"}), 409
573+
574+
try:
575+
jupyter_process = subprocess.Popen(
576+
['jupyter', 'lab', '--no-browser'],
577+
shell=False,
578+
cwd=concore_path,
579+
stdout=subprocess.DEVNULL,
580+
stderr=subprocess.DEVNULL
581+
)
582+
return jsonify({"message": "Jupyter Lab started"}), 200
583+
except (FileNotFoundError, PermissionError, OSError) as e:
584+
logger.error("Failed to start Jupyter: %s", e)
585+
return jsonify({"error": "Failed to start Jupyter"}), 500
586+
587+
588+
@app.route('/stopJupyter/', methods=['POST'])
589+
def stopJupyter():
590+
global jupyter_process
591+
592+
require_api_key()
593+
594+
with jupyter_lock:
595+
if not jupyter_process or jupyter_process.poll() is not None:
596+
return jsonify({"message": "No running Jupyter instance"}), 404
597+
598+
try:
599+
jupyter_process.terminate()
600+
# Wait for Jupyter to terminate gracefully
601+
jupyter_process.wait(timeout=5)
602+
except subprocess.TimeoutExpired:
603+
# Force kill if it did not exit in time
604+
jupyter_process.kill()
605+
jupyter_process.wait(timeout=5)
606+
finally:
607+
jupyter_process = None
608+
609+
return jsonify({"message": "Jupyter stopped"}), 200
555610

556611

557612
if __name__ == "__main__":

tests/test_openjupyter_security.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Tests for the secured /openJupyter/ and /stopJupyter/ endpoints."""
2+
import os
3+
import sys
4+
import pytest
5+
from unittest.mock import patch, MagicMock
6+
7+
# Ensure the project root is on the path
8+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9+
10+
# Skip entire module if flask is not installed (e.g. in CI with minimal deps)
11+
pytest.importorskip("flask", reason="flask not installed — skipping server endpoint tests")
12+
13+
# Set a test API key before importing the app module
14+
TEST_API_KEY = "test-secret-key-12345"
15+
16+
17+
@pytest.fixture(autouse=True)
18+
def reset_jupyter_process():
19+
"""Reset the module-level jupyter_process before each test."""
20+
import fri.server.main as mod
21+
mod.jupyter_process = None
22+
yield
23+
mod.jupyter_process = None
24+
25+
26+
@pytest.fixture
27+
def client():
28+
"""Create a Flask test client with the API key configured."""
29+
with patch.dict(os.environ, {"CONCORE_API_KEY": TEST_API_KEY}):
30+
# Re-read env var after patching
31+
import fri.server.main as mod
32+
mod.API_KEY = TEST_API_KEY
33+
mod.app.config["TESTING"] = True
34+
with mod.app.test_client() as c:
35+
yield c
36+
37+
38+
@pytest.fixture
39+
def client_no_key():
40+
"""Create a Flask test client without API key configured."""
41+
import fri.server.main as mod
42+
mod.API_KEY = None
43+
mod.app.config["TESTING"] = True
44+
with mod.app.test_client() as c:
45+
yield c
46+
47+
48+
class TestOpenJupyterAuth:
49+
"""Test authentication on /openJupyter/ endpoint."""
50+
51+
def test_missing_api_key_header_returns_403(self, client):
52+
"""Request without X-API-KEY header should be rejected."""
53+
resp = client.post("/openJupyter/")
54+
assert resp.status_code == 403
55+
56+
def test_wrong_api_key_returns_403(self, client):
57+
"""Request with wrong key should be rejected."""
58+
resp = client.post("/openJupyter/", headers={"X-API-KEY": "wrong-key"})
59+
assert resp.status_code == 403
60+
61+
def test_server_without_api_key_configured_returns_500(self, client_no_key):
62+
"""If CONCORE_API_KEY is not set on server, return 500."""
63+
resp = client_no_key.post(
64+
"/openJupyter/", headers={"X-API-KEY": "anything"}
65+
)
66+
assert resp.status_code == 500
67+
68+
69+
class TestOpenJupyterProcess:
70+
"""Test process control on /openJupyter/ endpoint."""
71+
72+
@patch("fri.server.main.subprocess.Popen")
73+
def test_authorized_request_starts_jupyter(self, mock_popen, client):
74+
"""Valid API key should start Jupyter Lab."""
75+
mock_proc = MagicMock()
76+
mock_proc.poll.return_value = None # process running
77+
mock_popen.return_value = mock_proc
78+
79+
resp = client.post(
80+
"/openJupyter/", headers={"X-API-KEY": TEST_API_KEY}
81+
)
82+
assert resp.status_code == 200
83+
data = resp.get_json()
84+
assert data["message"] == "Jupyter Lab started"
85+
86+
# Verify Popen was called with --no-browser and DEVNULL
87+
call_args = mock_popen.call_args
88+
assert "--no-browser" in call_args[0][0]
89+
assert call_args[1].get("shell") is False
90+
91+
@patch("fri.server.main.subprocess.Popen")
92+
def test_duplicate_launch_returns_409(self, mock_popen, client):
93+
"""Second launch while first is still running should return 409."""
94+
mock_proc = MagicMock()
95+
mock_proc.poll.return_value = None # still running
96+
mock_popen.return_value = mock_proc
97+
98+
# First launch
99+
resp1 = client.post(
100+
"/openJupyter/", headers={"X-API-KEY": TEST_API_KEY}
101+
)
102+
assert resp1.status_code == 200
103+
104+
# Second launch should be rejected
105+
resp2 = client.post(
106+
"/openJupyter/", headers={"X-API-KEY": TEST_API_KEY}
107+
)
108+
assert resp2.status_code == 409
109+
data = resp2.get_json()
110+
assert data["message"] == "Jupyter already running"
111+
112+
@patch("fri.server.main.subprocess.Popen", side_effect=OSError("fail"))
113+
def test_popen_failure_returns_500(self, mock_popen, client):
114+
"""If Popen raises, return 500."""
115+
resp = client.post(
116+
"/openJupyter/", headers={"X-API-KEY": TEST_API_KEY}
117+
)
118+
assert resp.status_code == 500
119+
data = resp.get_json()
120+
assert "error" in data
121+
122+
123+
class TestStopJupyter:
124+
"""Test /stopJupyter/ endpoint."""
125+
126+
def test_stop_without_auth_returns_403(self, client):
127+
"""Request without API key should be rejected."""
128+
resp = client.post("/stopJupyter/")
129+
assert resp.status_code == 403
130+
131+
def test_stop_when_no_process_returns_404(self, client):
132+
"""Stop with no running process returns 404."""
133+
resp = client.post(
134+
"/stopJupyter/", headers={"X-API-KEY": TEST_API_KEY}
135+
)
136+
assert resp.status_code == 404
137+
138+
@patch("fri.server.main.subprocess.Popen")
139+
def test_stop_running_process_returns_200(self, mock_popen, client):
140+
"""Stop a running Jupyter instance returns 200."""
141+
mock_proc = MagicMock()
142+
mock_proc.poll.return_value = None # running
143+
mock_popen.return_value = mock_proc
144+
145+
# Start first
146+
client.post("/openJupyter/", headers={"X-API-KEY": TEST_API_KEY})
147+
148+
# Stop
149+
resp = client.post(
150+
"/stopJupyter/", headers={"X-API-KEY": TEST_API_KEY}
151+
)
152+
assert resp.status_code == 200
153+
data = resp.get_json()
154+
assert data["message"] == "Jupyter stopped"
155+
mock_proc.terminate.assert_called_once()
156+
mock_proc.wait.assert_called()

0 commit comments

Comments
 (0)