Skip to content

Commit 9fe3608

Browse files
security: protect /openJupyter endpoint with API key and process control (fixes #361)
1 parent 0467ce5 commit 9fe3608

2 files changed

Lines changed: 199 additions & 9 deletions

File tree

fri/server/main.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ def get_error_output(e):
8484
cur_path = os.path.dirname(os.path.abspath(__file__))
8585
concore_path = os.path.abspath(os.path.join(cur_path, '../../'))
8686

87+
# API key authentication for sensitive endpoints
88+
API_KEY = os.environ.get("CONCORE_API_KEY")
89+
90+
def require_api_key():
91+
"""Require a valid API key via X-API-KEY header."""
92+
if not API_KEY:
93+
abort(500, description="Server not configured with API key")
94+
provided = request.headers.get("X-API-KEY")
95+
if provided != API_KEY:
96+
abort(403, description="Unauthorized")
97+
98+
# Track single Jupyter process to prevent multiple concurrent launches
99+
jupyter_process = None
100+
87101

88102
app = Flask(__name__)
89103
secret_key = os.environ.get("FLASK_SECRET_KEY")
@@ -536,15 +550,39 @@ def getFilesList(dir):
536550

537551
@app.route('/openJupyter/', methods=['POST'])
538552
def openJupyter():
539-
proc = subprocess.Popen(['jupyter', 'lab'], shell=False, stdout=subprocess.PIPE, cwd=concore_path)
540-
if proc.poll() is None:
541-
resp = jsonify({'message': 'Successfuly opened Jupyter'})
542-
resp.status_code = 308
543-
return resp
544-
else:
545-
resp = jsonify({'message': 'There is an Error'})
546-
resp.status_code = 500
547-
return resp
553+
global jupyter_process
554+
555+
require_api_key()
556+
557+
if jupyter_process and jupyter_process.poll() is None:
558+
return jsonify({"message": "Jupyter already running"}), 409
559+
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
571+
572+
573+
@app.route('/stopJupyter/', methods=['POST'])
574+
def stopJupyter():
575+
global jupyter_process
576+
577+
require_api_key()
578+
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
584+
585+
return jsonify({"message": "Jupyter stopped"}), 200
548586

549587

550588
if __name__ == "__main__":

tests/test_openjupyter_security.py

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

0 commit comments

Comments
 (0)