@@ -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