-
Notifications
You must be signed in to change notification settings - Fork 32
security: protect /openJupyter endpoint with API key and process control (fixes #361) #409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
pradeeban
merged 3 commits into
ControlCore-Project:dev
from
GaneshPatil7517:security/protect-openjupyter-endpoint
Feb 19, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
9fe3608
security: protect /openJupyter endpoint with API key and process cont…
GaneshPatil7517 5bf69a2
test: skip openJupyter security tests when flask is not installed in CI
GaneshPatil7517 51e7076
security: address PR review - timing-safe comparison, thread lock, sp…
GaneshPatil7517 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| """Tests for the secured /openJupyter/ and /stopJupyter/ endpoints.""" | ||
| import os | ||
| import sys | ||
| import pytest | ||
| from unittest.mock import patch, MagicMock | ||
|
|
||
| # Ensure the project root is on the path | ||
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | ||
|
|
||
| # Skip entire module if flask is not installed (e.g. in CI with minimal deps) | ||
| pytest.importorskip("flask", reason="flask not installed — skipping server endpoint tests") | ||
|
|
||
| # Set a test API key before importing the app module | ||
| TEST_API_KEY = "test-secret-key-12345" | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def reset_jupyter_process(): | ||
| """Reset the module-level jupyter_process before each test.""" | ||
| import fri.server.main as mod | ||
| mod.jupyter_process = None | ||
| yield | ||
| mod.jupyter_process = None | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def client(): | ||
| """Create a Flask test client with the API key configured.""" | ||
| with patch.dict(os.environ, {"CONCORE_API_KEY": TEST_API_KEY}): | ||
| # Re-read env var after patching | ||
| import fri.server.main as mod | ||
| mod.API_KEY = TEST_API_KEY | ||
| mod.app.config["TESTING"] = True | ||
| with mod.app.test_client() as c: | ||
| yield c | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def client_no_key(): | ||
| """Create a Flask test client without API key configured.""" | ||
| import fri.server.main as mod | ||
| mod.API_KEY = None | ||
| mod.app.config["TESTING"] = True | ||
| with mod.app.test_client() as c: | ||
| yield c | ||
|
|
||
|
|
||
| class TestOpenJupyterAuth: | ||
| """Test authentication on /openJupyter/ endpoint.""" | ||
|
|
||
| def test_missing_api_key_header_returns_403(self, client): | ||
| """Request without X-API-KEY header should be rejected.""" | ||
| resp = client.post("/openJupyter/") | ||
| assert resp.status_code == 403 | ||
|
|
||
| def test_wrong_api_key_returns_403(self, client): | ||
| """Request with wrong key should be rejected.""" | ||
| resp = client.post("/openJupyter/", headers={"X-API-KEY": "wrong-key"}) | ||
| assert resp.status_code == 403 | ||
|
|
||
| def test_server_without_api_key_configured_returns_500(self, client_no_key): | ||
| """If CONCORE_API_KEY is not set on server, return 500.""" | ||
| resp = client_no_key.post( | ||
| "/openJupyter/", headers={"X-API-KEY": "anything"} | ||
| ) | ||
| assert resp.status_code == 500 | ||
|
|
||
|
|
||
| class TestOpenJupyterProcess: | ||
| """Test process control on /openJupyter/ endpoint.""" | ||
|
|
||
| @patch("fri.server.main.subprocess.Popen") | ||
| def test_authorized_request_starts_jupyter(self, mock_popen, client): | ||
| """Valid API key should start Jupyter Lab.""" | ||
| mock_proc = MagicMock() | ||
| mock_proc.poll.return_value = None # process running | ||
| mock_popen.return_value = mock_proc | ||
|
|
||
| resp = client.post( | ||
| "/openJupyter/", headers={"X-API-KEY": TEST_API_KEY} | ||
| ) | ||
| assert resp.status_code == 200 | ||
| data = resp.get_json() | ||
| assert data["message"] == "Jupyter Lab started" | ||
|
|
||
| # Verify Popen was called with --no-browser and DEVNULL | ||
| call_args = mock_popen.call_args | ||
| assert "--no-browser" in call_args[0][0] | ||
| assert call_args[1].get("shell") is False | ||
|
|
||
| @patch("fri.server.main.subprocess.Popen") | ||
| def test_duplicate_launch_returns_409(self, mock_popen, client): | ||
| """Second launch while first is still running should return 409.""" | ||
| mock_proc = MagicMock() | ||
| mock_proc.poll.return_value = None # still running | ||
| mock_popen.return_value = mock_proc | ||
|
|
||
| # First launch | ||
| resp1 = client.post( | ||
| "/openJupyter/", headers={"X-API-KEY": TEST_API_KEY} | ||
| ) | ||
| assert resp1.status_code == 200 | ||
|
|
||
| # Second launch should be rejected | ||
| resp2 = client.post( | ||
| "/openJupyter/", headers={"X-API-KEY": TEST_API_KEY} | ||
| ) | ||
| assert resp2.status_code == 409 | ||
| data = resp2.get_json() | ||
| assert data["message"] == "Jupyter already running" | ||
|
|
||
| @patch("fri.server.main.subprocess.Popen", side_effect=OSError("fail")) | ||
| def test_popen_failure_returns_500(self, mock_popen, client): | ||
| """If Popen raises, return 500.""" | ||
| resp = client.post( | ||
| "/openJupyter/", headers={"X-API-KEY": TEST_API_KEY} | ||
| ) | ||
| assert resp.status_code == 500 | ||
| data = resp.get_json() | ||
| assert "error" in data | ||
|
|
||
|
|
||
| class TestStopJupyter: | ||
| """Test /stopJupyter/ endpoint.""" | ||
|
|
||
| def test_stop_without_auth_returns_403(self, client): | ||
| """Request without API key should be rejected.""" | ||
| resp = client.post("/stopJupyter/") | ||
| assert resp.status_code == 403 | ||
|
|
||
| def test_stop_when_no_process_returns_404(self, client): | ||
| """Stop with no running process returns 404.""" | ||
| resp = client.post( | ||
| "/stopJupyter/", headers={"X-API-KEY": TEST_API_KEY} | ||
| ) | ||
| assert resp.status_code == 404 | ||
|
|
||
| @patch("fri.server.main.subprocess.Popen") | ||
| def test_stop_running_process_returns_200(self, mock_popen, client): | ||
| """Stop a running Jupyter instance returns 200.""" | ||
| mock_proc = MagicMock() | ||
| mock_proc.poll.return_value = None # running | ||
| mock_popen.return_value = mock_proc | ||
|
|
||
| # Start first | ||
| client.post("/openJupyter/", headers={"X-API-KEY": TEST_API_KEY}) | ||
|
|
||
| # Stop | ||
| resp = client.post( | ||
| "/stopJupyter/", headers={"X-API-KEY": TEST_API_KEY} | ||
| ) | ||
| assert resp.status_code == 200 | ||
| data = resp.get_json() | ||
| assert data["message"] == "Jupyter stopped" | ||
| mock_proc.terminate.assert_called_once() | ||
| mock_proc.wait.assert_called() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.