mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
9515130b2a
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
5.6 KiB
Python
169 lines
5.6 KiB
Python
"""Tests for the read_content MCP tool security validation.
|
|
|
|
We keep these tests focused on path boundary/security checks, and rely on
|
|
`tests/mcp/test_tool_resource.py` for full-stack content-type behavior.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from mcp.server.fastmcp.exceptions import ToolError
|
|
|
|
from basic_memory.mcp.tools import read_content, write_note
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_blocks_path_traversal_unix(client, test_project):
|
|
attack_paths = [
|
|
"../secrets.txt",
|
|
"../../etc/passwd",
|
|
"../../../root/.ssh/id_rsa",
|
|
"notes/../../../etc/shadow",
|
|
"folder/../../outside/file.md",
|
|
"../../../../etc/hosts",
|
|
"../../../home/user/.env",
|
|
]
|
|
|
|
for attack_path in attack_paths:
|
|
result = await read_content(project=test_project.name, path=attack_path)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
assert attack_path in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_blocks_path_traversal_windows(client, test_project):
|
|
attack_paths = [
|
|
"..\\secrets.txt",
|
|
"..\\..\\Windows\\System32\\config\\SAM",
|
|
"notes\\..\\..\\..\\Windows\\System32",
|
|
"\\\\server\\share\\file.txt",
|
|
"..\\..\\Users\\user\\.env",
|
|
"\\\\..\\..\\Windows",
|
|
"..\\..\\..\\Boot.ini",
|
|
]
|
|
|
|
for attack_path in attack_paths:
|
|
result = await read_content(project=test_project.name, path=attack_path)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
assert attack_path in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_blocks_absolute_paths(client, test_project):
|
|
attack_paths = [
|
|
"/etc/passwd",
|
|
"/home/user/.env",
|
|
"/var/log/auth.log",
|
|
"/root/.ssh/id_rsa",
|
|
"C:\\Windows\\System32\\config\\SAM",
|
|
"C:\\Users\\user\\.env",
|
|
"D:\\secrets\\config.json",
|
|
"/tmp/malicious.txt",
|
|
"/usr/local/bin/evil",
|
|
]
|
|
|
|
for attack_path in attack_paths:
|
|
result = await read_content(project=test_project.name, path=attack_path)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
assert attack_path in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_blocks_home_directory_access(client, test_project):
|
|
attack_paths = [
|
|
"~/secrets.txt",
|
|
"~/.env",
|
|
"~/.ssh/id_rsa",
|
|
"~/Documents/passwords.txt",
|
|
"~\\AppData\\secrets",
|
|
"~\\Desktop\\config.ini",
|
|
"~/.bashrc",
|
|
"~/Library/Preferences/secret.plist",
|
|
]
|
|
|
|
for attack_path in attack_paths:
|
|
result = await read_content(project=test_project.name, path=attack_path)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
assert attack_path in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_blocks_memory_url_attacks(client, test_project):
|
|
attack_paths = [
|
|
"memory://../../etc/passwd",
|
|
"memory://../../../root/.ssh/id_rsa",
|
|
"memory://~/.env",
|
|
"memory:///etc/passwd",
|
|
]
|
|
|
|
for attack_path in attack_paths:
|
|
result = await read_content(project=test_project.name, path=attack_path)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_unicode_path_attacks(client, test_project):
|
|
unicode_attacks = [
|
|
"notes/文档/../../../etc/passwd",
|
|
"docs/café/../../.env",
|
|
"files/αβγ/../../../secret.txt",
|
|
]
|
|
|
|
for attack_path in unicode_attacks:
|
|
result = await read_content(project=test_project.name, path=attack_path)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_very_long_attack_path(client, test_project):
|
|
long_attack = "../" * 1000 + "etc/passwd"
|
|
result = await read_content(project=test_project.name, path=long_attack)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_case_variations_attacks(client, test_project):
|
|
case_attacks = [
|
|
"../ETC/passwd",
|
|
"../Etc/PASSWD",
|
|
"..\\WINDOWS\\system32",
|
|
"~/.SSH/id_rsa",
|
|
]
|
|
|
|
for attack_path in case_attacks:
|
|
result = await read_content(project=test_project.name, path=attack_path)
|
|
assert result["type"] == "error"
|
|
assert "paths must stay within project boundaries" in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_allows_safe_path_integration(client, test_project):
|
|
await write_note(
|
|
project=test_project.name,
|
|
title="Meeting",
|
|
directory="notes",
|
|
content="This is a safe note for read_content()",
|
|
)
|
|
|
|
result = await read_content(project=test_project.name, path="notes/meeting")
|
|
assert result["type"] == "text"
|
|
assert "safe note" in result["text"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_content_empty_path_does_not_trigger_security_error(client, test_project):
|
|
try:
|
|
result = await read_content(project=test_project.name, path="")
|
|
if isinstance(result, dict) and result.get("type") == "error":
|
|
assert "paths must stay within project boundaries" not in result.get("error", "")
|
|
except ToolError:
|
|
# Acceptable: resource resolution may treat empty path as not-found.
|
|
pass
|