Skip to content

Commit 7616b2b

Browse files
phernandezclaude
andauthored
fix: cloud mode path validation and sanitization (bmc-issue-103) (#332)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14c1fe4 commit 7616b2b

4 files changed

Lines changed: 175 additions & 6 deletions

File tree

src/basic_memory/api/routers/project_router.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ async def add_project(
194194
Response confirming the project was added
195195
"""
196196
try: # pragma: no cover
197+
# The service layer now handles cloud mode validation and path sanitization
197198
await project_service.add_project(
198199
project_data.name, project_data.path, set_default=project_data.set_default
199200
)

src/basic_memory/services/project_service.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,30 @@ async def add_project(self, name: str, path: str, set_default: bool = False) ->
100100
ValueError: If the project already exists
101101
"""
102102
# in cloud mode, don't allow arbitrary paths.
103-
if config.cloud_mode:
103+
if self.config_manager.config.cloud_mode_enabled:
104104
basic_memory_home = os.getenv("BASIC_MEMORY_HOME")
105105
assert basic_memory_home is not None
106106
base_path = Path(basic_memory_home)
107107

108-
# Resolve to absolute path
109-
resolved_path = Path(os.path.abspath(os.path.expanduser(base_path / path))).as_posix()
108+
# Sanitize the input path for cloud mode
109+
# Strip leading slashes, home directory references, and parent directory references
110+
clean_path = path.lstrip("/").replace("~/", "").replace("~", "")
111+
112+
# Remove any parent directory traversal attempts
113+
path_parts = []
114+
for part in clean_path.split("/"):
115+
if part and part != "." and part != "..":
116+
path_parts.append(part)
117+
clean_path = "/".join(path_parts) if path_parts else ""
118+
119+
# Construct path relative to BASIC_MEMORY_HOME
120+
resolved_path = (base_path / clean_path).resolve().as_posix()
121+
122+
# Verify the resolved path is actually under BASIC_MEMORY_HOME
123+
if not resolved_path.startswith(base_path.resolve().as_posix()):
124+
raise ValueError(
125+
f"Cloud mode requires projects under {basic_memory_home}. Invalid path: {path}"
126+
)
110127
else:
111128
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
112129

test-int/test_disable_permalinks_integration.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
"""Integration tests for the disable_permalinks configuration."""
22

33
import pytest
4-
from pathlib import Path
5-
from textwrap import dedent
64

75
from basic_memory.config import BasicMemoryConfig
86
from basic_memory.markdown import EntityParser, MarkdownProcessor
9-
from basic_memory.models import Project
107
from basic_memory.repository import (
118
EntityRepository,
129
ObservationRepository,

tests/services/test_project_service.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,3 +714,157 @@ async def test_synchronize_projects_handles_case_sensitivity_bug(
714714
db_project = await project_service.repository.get_by_name(name)
715715
if db_project:
716716
await project_service.repository.delete(db_project.id)
717+
718+
719+
@pytest.mark.skipif(os.name == "nt", reason="Cloud mode only runs on POSIX systems")
720+
@pytest.mark.asyncio
721+
async def test_add_project_cloud_mode_sanitizes_paths(
722+
project_service: ProjectService, config_manager: ConfigManager, tmp_path, monkeypatch
723+
):
724+
"""Test that cloud mode sanitizes and validates project paths."""
725+
# Set up cloud mode environment
726+
cloud_home = tmp_path / "app" / "data" / "basic-memory"
727+
cloud_home.mkdir(parents=True, exist_ok=True)
728+
729+
monkeypatch.setenv("BASIC_MEMORY_HOME", str(cloud_home))
730+
monkeypatch.setenv("BASIC_MEMORY_CLOUD_MODE", "true")
731+
732+
# Force reload config to pick up cloud mode
733+
from basic_memory.services import project_service as ps_module
734+
735+
monkeypatch.setattr(ps_module, "config", config_manager.load_config())
736+
737+
test_cases = [
738+
# (input_path, expected_result_path, should_succeed)
739+
("test", str(cloud_home / "test"), True), # Simple relative path
740+
("~/Documents/test", str(cloud_home / "Documents" / "test"), True), # Home directory
741+
(
742+
"/tmp/test",
743+
str(cloud_home / "tmp" / "test"),
744+
True,
745+
), # Absolute path (sanitized to relative)
746+
(
747+
"../../../etc/passwd",
748+
str(cloud_home),
749+
True,
750+
), # Path traversal (all ../ removed, results in cloud_home)
751+
("folder/subfolder", str(cloud_home / "folder" / "subfolder"), True), # Nested path
752+
(
753+
"~/folder/../test",
754+
str(cloud_home / "test"),
755+
True,
756+
), # Mixed patterns (sanitized to just 'test')
757+
]
758+
759+
for i, (input_path, expected_path, should_succeed) in enumerate(test_cases):
760+
test_project_name = f"cloud-test-{i}"
761+
762+
try:
763+
# Add the project
764+
await project_service.add_project(test_project_name, input_path)
765+
766+
if should_succeed:
767+
# Verify the path was sanitized correctly
768+
assert test_project_name in project_service.projects
769+
actual_path = project_service.projects[test_project_name]
770+
771+
# The path should be under cloud_home
772+
assert actual_path.startswith(str(cloud_home)), (
773+
f"Path {actual_path} should start with {cloud_home} for input {input_path}"
774+
)
775+
776+
# Clean up
777+
await project_service.remove_project(test_project_name)
778+
else:
779+
pytest.fail(f"Expected ValueError for input path: {input_path}")
780+
781+
except ValueError as e:
782+
if should_succeed:
783+
pytest.fail(f"Unexpected ValueError for input path {input_path}: {e}")
784+
# Expected failure - continue to next test case
785+
786+
787+
@pytest.mark.skipif(os.name == "nt", reason="Cloud mode only runs on POSIX systems")
788+
@pytest.mark.asyncio
789+
async def test_add_project_cloud_mode_rejects_escape_attempts(
790+
project_service: ProjectService, config_manager: ConfigManager, tmp_path, monkeypatch
791+
):
792+
"""Test that cloud mode rejects paths that try to escape cloud storage."""
793+
# Set up cloud mode environment
794+
cloud_home = tmp_path / "app" / "data" / "basic-memory"
795+
cloud_home.mkdir(parents=True, exist_ok=True)
796+
797+
# Create a directory outside cloud_home to verify it's not accessible
798+
outside_dir = tmp_path / "outside"
799+
outside_dir.mkdir(parents=True, exist_ok=True)
800+
801+
monkeypatch.setenv("BASIC_MEMORY_HOME", str(cloud_home))
802+
monkeypatch.setenv("BASIC_MEMORY_CLOUD_MODE", "true")
803+
804+
# Force reload config to pick up cloud mode
805+
from basic_memory.services import project_service as ps_module
806+
807+
monkeypatch.setattr(ps_module, "config", config_manager.load_config())
808+
809+
# All of these should succeed by being sanitized to paths under cloud_home
810+
# The sanitization removes dangerous patterns, so they don't escape
811+
safe_after_sanitization = [
812+
"../../../etc/passwd",
813+
"../../.env",
814+
"../../../home/user/.ssh/id_rsa",
815+
]
816+
817+
for i, attack_path in enumerate(safe_after_sanitization):
818+
test_project_name = f"cloud-attack-test-{i}"
819+
820+
try:
821+
# Add the project
822+
await project_service.add_project(test_project_name, attack_path)
823+
824+
# Verify it was sanitized to be under cloud_home
825+
actual_path = project_service.projects[test_project_name]
826+
assert actual_path.startswith(str(cloud_home)), (
827+
f"Sanitized path {actual_path} should be under {cloud_home}"
828+
)
829+
830+
# Clean up
831+
await project_service.remove_project(test_project_name)
832+
833+
except ValueError:
834+
# If it raises ValueError, that's also acceptable for security
835+
pass
836+
837+
838+
@pytest.mark.skipif(os.name == "nt", reason="Cloud mode only runs on POSIX systems")
839+
@pytest.mark.asyncio
840+
async def test_add_project_local_mode_allows_arbitrary_paths(
841+
project_service: ProjectService, config_manager: ConfigManager, tmp_path, monkeypatch
842+
):
843+
"""Test that local mode (non-cloud) still allows arbitrary paths."""
844+
# Ensure cloud mode is disabled
845+
monkeypatch.setenv("BASIC_MEMORY_CLOUD_MODE", "false")
846+
847+
# Force reload config to pick up local mode
848+
from basic_memory.services import project_service as ps_module
849+
850+
monkeypatch.setattr(ps_module, "config", config_manager.load_config())
851+
852+
# Create a test directory
853+
test_dir = tmp_path / "arbitrary-location"
854+
test_dir.mkdir(parents=True, exist_ok=True)
855+
856+
test_project_name = "local-mode-test"
857+
858+
try:
859+
# In local mode, we should be able to use arbitrary absolute paths
860+
await project_service.add_project(test_project_name, str(test_dir))
861+
862+
# Verify the path was accepted as-is
863+
assert test_project_name in project_service.projects
864+
actual_path = project_service.projects[test_project_name]
865+
assert actual_path == str(test_dir)
866+
867+
finally:
868+
# Clean up
869+
if test_project_name in project_service.projects:
870+
await project_service.remove_project(test_project_name)

0 commit comments

Comments
 (0)