Skip to content

Commit 24a1d61

Browse files
authored
fix: path traversal security vulnerability in mcp tools (#223)
Signed-off-by: Joe P <joe@basicmemory.com>
1 parent a0cf623 commit 24a1d61

12 files changed

Lines changed: 1939 additions & 17 deletions

src/basic_memory/mcp/async_client.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,25 @@
44
from basic_memory.api.app import app as fastapi_app
55
from basic_memory.config import ConfigManager
66

7+
78
def create_client() -> AsyncClient:
89
"""Create an HTTP client based on configuration.
9-
10+
1011
Returns:
1112
AsyncClient configured for either local ASGI or remote HTTP transport
1213
"""
1314
config_manager = ConfigManager()
1415
config = config_manager.load_config()
15-
16+
1617
if config.api_url:
1718
# Use HTTP transport for remote API
1819
logger.info(f"Creating HTTP client for remote Basic Memory API: {config.api_url}")
1920
return AsyncClient(base_url=config.api_url)
2021
else:
2122
# Use ASGI transport for local API
2223
logger.debug("Creating ASGI client for local Basic Memory API")
23-
return AsyncClient(
24-
transport=ASGITransport(app=fastapi_app),
25-
base_url="http://test"
26-
)
24+
return AsyncClient(transport=ASGITransport(app=fastapi_app), base_url="http://test")
25+
2726

2827
# Create shared async client
2928
client = create_client()

src/basic_memory/mcp/tools/move_note.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from basic_memory.mcp.project_session import get_active_project
1212
from basic_memory.schemas import EntityResponse
1313
from basic_memory.schemas.project_info import ProjectList
14+
from basic_memory.utils import validate_project_path
1415

1516

1617
async def _detect_cross_project_move_attempt(
@@ -393,6 +394,28 @@ async def move_note(
393394
active_project = get_active_project(project)
394395
project_url = active_project.project_url
395396

397+
# Validate destination path to prevent path traversal attacks
398+
project_path = active_project.home
399+
if not validate_project_path(destination_path, project_path):
400+
logger.warning(
401+
"Attempted path traversal attack blocked",
402+
destination_path=destination_path,
403+
project=active_project.name,
404+
)
405+
return f"""# Move Failed - Security Validation Error
406+
407+
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
408+
409+
## Valid path examples:
410+
- `notes/my-file.md`
411+
- `projects/2025/meeting-notes.md`
412+
- `archive/old-notes.md`
413+
414+
## Try again with a safe path:
415+
```
416+
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
417+
```"""
418+
396419
# Check for potential cross-project move attempts
397420
cross_project_error = await _detect_cross_project_move_attempt(
398421
identifier, destination_path, active_project.name

src/basic_memory/mcp/tools/read_content.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from basic_memory.mcp.tools.utils import call_get
1818
from basic_memory.mcp.project_session import get_active_project
1919
from basic_memory.schemas.memory import memory_url_path
20+
from basic_memory.utils import validate_project_path
2021

2122

2223
def calculate_target_params(content_length):
@@ -188,6 +189,21 @@ async def read_content(path: str, project: Optional[str] = None) -> dict:
188189
project_url = active_project.project_url
189190

190191
url = memory_url_path(path)
192+
193+
# Validate path to prevent path traversal attacks
194+
project_path = active_project.home
195+
if not validate_project_path(url, project_path):
196+
logger.warning(
197+
"Attempted path traversal attack blocked",
198+
path=path,
199+
url=url,
200+
project=active_project.name,
201+
)
202+
return {
203+
"type": "error",
204+
"error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
205+
}
206+
191207
response = await call_get(client, f"{project_url}/resource/{url}")
192208
content_type = response.headers.get("content-type", "application/octet-stream")
193209
content_length = int(response.headers.get("content-length", 0))

src/basic_memory/mcp/tools/read_note.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from basic_memory.mcp.tools.utils import call_get
1212
from basic_memory.mcp.project_session import get_active_project
1313
from basic_memory.schemas.memory import memory_url_path
14+
from basic_memory.utils import validate_project_path
1415

1516

1617
@mcp.tool(
@@ -67,6 +68,17 @@ async def read_note(
6768

6869
# Get the file via REST API - first try direct permalink lookup
6970
entity_path = memory_url_path(identifier)
71+
72+
# Validate path to prevent path traversal attacks
73+
project_path = active_project.home
74+
if not validate_project_path(entity_path, project_path):
75+
logger.warning(
76+
"Attempted path traversal attack blocked",
77+
identifier=identifier,
78+
entity_path=entity_path,
79+
project=active_project.name,
80+
)
81+
return f"# Error\n\nPath '{identifier}' is not allowed - paths must stay within project boundaries"
7082
path = f"{project_url}/resource/{entity_path}"
7183
logger.info(f"Attempting to read note from URL: {path}")
7284

src/basic_memory/mcp/tools/write_note.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from basic_memory.mcp.project_session import get_active_project
1111
from basic_memory.schemas import EntityResponse
1212
from basic_memory.schemas.base import Entity
13-
from basic_memory.utils import parse_tags
13+
from basic_memory.utils import parse_tags, validate_project_path
1414

1515
# Define TagType as a Union that can accept either a string or a list of strings or None
1616
TagType = Union[List[str], str, None]
@@ -75,6 +75,14 @@ async def write_note(
7575
# Get the active project first to check project-specific sync status
7676
active_project = get_active_project(project)
7777

78+
# Validate folder path to prevent path traversal attacks
79+
project_path = active_project.home
80+
if folder and not validate_project_path(folder, project_path):
81+
logger.warning(
82+
"Attempted path traversal attack blocked", folder=folder, project=active_project.name
83+
)
84+
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
85+
7886
# Check migration status and wait briefly if needed
7987
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
8088

src/basic_memory/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,32 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
213213
except (ValueError, TypeError): # pragma: no cover
214214
logger.warning(f"Couldn't parse tags from input of type {type(tags)}: {tags}")
215215
return []
216+
217+
218+
def validate_project_path(path: str, project_path: Path) -> bool:
219+
"""Ensure path stays within project boundaries."""
220+
# Allow empty strings as they resolve to the project root
221+
if not path:
222+
return True
223+
224+
# Check for obvious path traversal patterns first
225+
if ".." in path or "~" in path:
226+
return False
227+
228+
# Check for Windows-style path traversal (even on Unix systems)
229+
if "\\.." in path or path.startswith("\\"):
230+
return False
231+
232+
# Block absolute paths (Unix-style starting with / or Windows-style with drive letters)
233+
if path.startswith("/") or (len(path) >= 2 and path[1] == ":"):
234+
return False
235+
236+
# Block paths with control characters (but allow whitespace that will be stripped)
237+
if path.strip() and any(ord(c) < 32 and c not in [' ', '\t'] for c in path):
238+
return False
239+
240+
try:
241+
resolved = (project_path / path).resolve()
242+
return resolved.is_relative_to(project_path.resolve())
243+
except (ValueError, OSError):
244+
return False

tests/api/test_async_client.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Tests for async_client configuration."""
22

3-
import pytest
43
from unittest.mock import patch
54
from httpx import AsyncClient, ASGITransport
65

@@ -11,12 +10,12 @@
1110
def test_create_client_uses_asgi_when_no_api_url():
1211
"""Test that create_client uses ASGI transport when api_url is None."""
1312
mock_config = BasicMemoryConfig(api_url=None)
14-
15-
with patch('basic_memory.mcp.async_client.ConfigManager') as mock_config_manager:
13+
14+
with patch("basic_memory.mcp.async_client.ConfigManager") as mock_config_manager:
1615
mock_config_manager.return_value.load_config.return_value = mock_config
17-
16+
1817
client = create_client()
19-
18+
2019
assert isinstance(client, AsyncClient)
2120
assert isinstance(client._transport, ASGITransport)
2221
assert str(client.base_url) == "http://test"
@@ -26,12 +25,12 @@ def test_create_client_uses_http_when_api_url_set():
2625
"""Test that create_client uses HTTP transport when api_url is configured."""
2726
remote_url = "https://api.basicmemory.example.com"
2827
mock_config = BasicMemoryConfig(api_url=remote_url)
29-
30-
with patch('basic_memory.mcp.async_client.ConfigManager') as mock_config_manager:
28+
29+
with patch("basic_memory.mcp.async_client.ConfigManager") as mock_config_manager:
3130
mock_config_manager.return_value.load_config.return_value = mock_config
32-
31+
3332
client = create_client()
34-
33+
3534
assert isinstance(client, AsyncClient)
3635
assert not isinstance(client._transport, ASGITransport)
37-
assert str(client.base_url) == remote_url
36+
assert str(client.base_url) == remote_url

0 commit comments

Comments
 (0)