Skip to content

Commit 0e97a26

Browse files
authored
Security: Validate Cursor deeplink URLs and use safer Windows API (#2348)
* 🤖 Security: Validate Cursor deeplink URLs and replace cmd.exe on Windows - Add URL scheme validation to reject non-cursor:// URLs - Replace subprocess cmd.exe call with os.startfile() on Windows - Add tests for scheme validation and error handling * 🤖 Fix tests for cross-platform deeplink validation
1 parent 5499cda commit 0e97a26

File tree

2 files changed

+57
-18
lines changed

2 files changed

+57
-18
lines changed

src/fastmcp/cli/install/cursor.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Cursor integration for FastMCP install using Cyclopts."""
22

33
import base64
4+
import os
45
import subprocess
56
import sys
67
from pathlib import Path
78
from typing import Annotated
9+
from urllib.parse import urlparse
810

911
import cyclopts
1012
from rich import print
@@ -51,17 +53,20 @@ def open_deeplink(deeplink: str) -> bool:
5153
Returns:
5254
True if the command succeeded, False otherwise
5355
"""
56+
parsed = urlparse(deeplink)
57+
if parsed.scheme != "cursor":
58+
logger.warning(f"Invalid deeplink scheme: {parsed.scheme}")
59+
return False
60+
5461
try:
5562
if sys.platform == "darwin": # macOS
5663
subprocess.run(["open", deeplink], check=True, capture_output=True)
5764
elif sys.platform == "win32": # Windows
58-
subprocess.run(
59-
["cmd", "/c", "start", deeplink], check=True, capture_output=True
60-
)
65+
os.startfile(deeplink)
6166
else: # Linux and others
6267
subprocess.run(["xdg-open", deeplink], check=True, capture_output=True)
6368
return True
64-
except (subprocess.CalledProcessError, FileNotFoundError):
69+
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
6570
return False
6671

6772

tests/cli/test_cursor.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,16 @@ def test_open_deeplink_macos(self, mock_run):
135135
["open", "cursor://test"], check=True, capture_output=True
136136
)
137137

138-
@patch("subprocess.run")
139-
def test_open_deeplink_windows(self, mock_run):
138+
def test_open_deeplink_windows(self):
140139
"""Test opening deeplink on Windows."""
141140
with patch("sys.platform", "win32"):
142-
mock_run.return_value = Mock(returncode=0)
141+
with patch(
142+
"fastmcp.cli.install.cursor.os.startfile", create=True
143+
) as mock_startfile:
144+
result = open_deeplink("cursor://test")
143145

144-
result = open_deeplink("cursor://test")
145-
146-
assert result is True
147-
mock_run.assert_called_once_with(
148-
["cmd", "/c", "start", "cursor://test"], check=True, capture_output=True
149-
)
146+
assert result is True
147+
mock_startfile.assert_called_once_with("cursor://test")
150148

151149
@patch("subprocess.run")
152150
def test_open_deeplink_linux(self, mock_run):
@@ -166,21 +164,57 @@ def test_open_deeplink_failure(self, mock_run):
166164
"""Test handling of deeplink opening failure."""
167165
import subprocess
168166

169-
mock_run.side_effect = subprocess.CalledProcessError(1, ["open"])
167+
with patch("sys.platform", "darwin"):
168+
mock_run.side_effect = subprocess.CalledProcessError(1, ["open"])
170169

171-
result = open_deeplink("cursor://test")
170+
result = open_deeplink("cursor://test")
172171

173-
assert result is False
172+
assert result is False
174173

175174
@patch("subprocess.run")
176175
def test_open_deeplink_command_not_found(self, mock_run):
177176
"""Test handling when open command is not found."""
178-
mock_run.side_effect = FileNotFoundError()
177+
with patch("sys.platform", "darwin"):
178+
mock_run.side_effect = FileNotFoundError()
179179

180-
result = open_deeplink("cursor://test")
180+
result = open_deeplink("cursor://test")
181+
182+
assert result is False
183+
184+
def test_open_deeplink_invalid_scheme(self):
185+
"""Test that non-cursor:// URLs are rejected."""
186+
result = open_deeplink("http://malicious.com")
187+
assert result is False
188+
189+
result = open_deeplink("https://example.com")
190+
assert result is False
181191

192+
result = open_deeplink("file:///etc/passwd")
182193
assert result is False
183194

195+
def test_open_deeplink_valid_cursor_scheme(self):
196+
"""Test that cursor:// URLs are accepted."""
197+
with patch("sys.platform", "darwin"):
198+
with patch("subprocess.run") as mock_run:
199+
mock_run.return_value = Mock(returncode=0)
200+
result = open_deeplink("cursor://anysphere.cursor-deeplink/mcp/install")
201+
assert result is True
202+
203+
def test_open_deeplink_empty_url(self):
204+
"""Test handling of empty URL."""
205+
result = open_deeplink("")
206+
assert result is False
207+
208+
def test_open_deeplink_windows_oserror(self):
209+
"""Test handling of OSError on Windows."""
210+
with patch("sys.platform", "win32"):
211+
with patch(
212+
"fastmcp.cli.install.cursor.os.startfile", create=True
213+
) as mock_startfile:
214+
mock_startfile.side_effect = OSError("File not found")
215+
result = open_deeplink("cursor://test")
216+
assert result is False
217+
184218

185219
class TestInstallCursor:
186220
"""Test cursor installation functionality."""

0 commit comments

Comments
 (0)