From 2b7689a408502c50e5556762f0195ab158679b7d Mon Sep 17 00:00:00 2001 From: Joe P Date: Sun, 15 Mar 2026 11:54:40 -0600 Subject: [PATCH 1/2] fix(core): allow double-dot filenames while still blocking path traversal Titles ending with a period (e.g. "Hi everyone.") produced filenames like "hi-everyone..md" which the old substring check ('..' in path) incorrectly blocked as path traversal. Fix by checking for ".." as a path segment only, and strip trailing periods in sanitize_for_filename to prevent double-dot filenames from being generated in the first place. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joe P --- src/basic_memory/file_utils.py | 5 +++ src/basic_memory/utils.py | 14 +++++--- tests/utils/test_file_utils.py | 13 +++++++ tests/utils/test_validate_project_path.py | 44 +++++++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/file_utils.py b/src/basic_memory/file_utils.py index 9c7ab7b1f..3de80b295 100644 --- a/src/basic_memory/file_utils.py +++ b/src/basic_memory/file_utils.py @@ -447,6 +447,11 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str: # compress multiple, repeated replacements text = re.sub(f"{re.escape(replacement)}+", replacement, text) + # Strip trailing periods — they cause "hi-everyone..md" double-dot filenames + # when ".md" is appended, which triggers path traversal false positives. + # Trailing periods are also invalid on Windows filesystems. + text = text.strip(".") + return text.strip(replacement) diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index ba36a87ea..ef53687e1 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -503,12 +503,18 @@ def valid_project_path_value(path: str): if not path: return True - # Check for obvious path traversal patterns first - if ".." in path or "~" in path: + # Check for tilde (home directory expansion) + if "~" in path: return False - # Check for Windows-style path traversal (even on Unix systems) - if "\\.." in path or path.startswith("\\"): + # Check for ".." as a path segment (path traversal), not as a substring. + # Filenames like "hi-everyone..md" are legitimate and must not be blocked. + segments = path.replace("\\", "/").split("/") + if any(seg == ".." for seg in segments): + return False + + # Check for Windows-style leading backslash + if path.startswith("\\"): return False # Block absolute paths (Unix-style starting with / or Windows-style with drive letters) diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py index a2025687e..d731bad38 100644 --- a/tests/utils/test_file_utils.py +++ b/tests/utils/test_file_utils.py @@ -198,6 +198,19 @@ def test_sanitize_for_filename_removes_invalid_characters(): assert char not in sanitized_text +def test_sanitize_for_filename_strips_trailing_periods(): + """Trailing periods cause double-dot filenames like 'hi-everyone..md'. + + This was a production bug where title "Hi everyone." produced file path + "hi-everyone..md" which failed path traversal validation. + """ + assert sanitize_for_filename("Hi everyone.") == "Hi everyone" + assert sanitize_for_filename("test...") == "test" + assert sanitize_for_filename(".hidden") == "hidden" + assert sanitize_for_filename("...dots...") == "dots" + assert sanitize_for_filename("normal title") == "normal title" + + @pytest.mark.parametrize( "input_directory,expected", [ diff --git a/tests/utils/test_validate_project_path.py b/tests/utils/test_validate_project_path.py index f5e87a6bb..f5d64837a 100644 --- a/tests/utils/test_validate_project_path.py +++ b/tests/utils/test_validate_project_path.py @@ -191,6 +191,50 @@ def test_absolute_paths(self, tmp_path): assert not result, f"Absolute path '{path}' should be blocked" +class TestValidateProjectPathDoubleDotInFilename: + """Test that filenames containing '..' as part of the name are allowed.""" + + def test_double_dot_in_filename_allowed(self, tmp_path): + """Filenames like 'hi-everyone..md' should NOT be blocked. + + This was a production bug: a title ending with a period (e.g. "Hi everyone.") + produced a file path like "hi-everyone..md" which the old substring check + ('..' in path) incorrectly flagged as path traversal. + """ + project_path = tmp_path / "project" + project_path.mkdir() + + safe_paths_with_dots = [ + "hi-everyone..md", + "notes/hi-everyone..md", + "version-2..0.md", + "file...name.md", + "docs/report..final.txt", + ] + + for path in safe_paths_with_dots: + assert validate_project_path(path, project_path), ( + f"Path '{path}' with '..' in filename should be allowed" + ) + + def test_actual_traversal_still_blocked(self, tmp_path): + """Ensure '..' as a path segment is still blocked.""" + project_path = tmp_path / "project" + project_path.mkdir() + + attack_paths = [ + "../file.md", + "notes/../../etc/passwd", + "foo/../../../bar", + "..\\Windows\\System32", + ] + + for path in attack_paths: + assert not validate_project_path(path, project_path), ( + f"Traversal path '{path}' should still be blocked" + ) + + class TestValidateProjectPathEdgeCases: """Test edge cases and error conditions.""" From 3903d359951519bc300a7b5d77abb17457ca282f Mon Sep 17 00:00:00 2001 From: Joe P Date: Sun, 15 Mar 2026 21:23:00 -0600 Subject: [PATCH 2/2] fix(core): harden traversal check against Windows dot/space normalization Block segments like ".. " and ".. ." that Windows normalizes to "..", preventing path traversal on Windows filesystems. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Joe P --- src/basic_memory/utils.py | 7 ++++++- tests/utils/test_validate_project_path.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index ef53687e1..34cc53574 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -509,8 +509,13 @@ def valid_project_path_value(path: str): # Check for ".." as a path segment (path traversal), not as a substring. # Filenames like "hi-everyone..md" are legitimate and must not be blocked. + # Also block segments like ".. " and ".. ." because Windows normalizes + # trailing dots and spaces away, making them equivalent to "..". segments = path.replace("\\", "/").split("/") - if any(seg == ".." for seg in segments): + if any( + seg == ".." or (len(seg) > 2 and seg[:2] == ".." and all(c in ". " for c in seg[2:])) + for seg in segments + ): return False # Check for Windows-style leading backslash diff --git a/tests/utils/test_validate_project_path.py b/tests/utils/test_validate_project_path.py index f5d64837a..7c6ed7670 100644 --- a/tests/utils/test_validate_project_path.py +++ b/tests/utils/test_validate_project_path.py @@ -227,6 +227,10 @@ def test_actual_traversal_still_blocked(self, tmp_path): "notes/../../etc/passwd", "foo/../../../bar", "..\\Windows\\System32", + # Windows normalizes trailing dots/spaces to ".." + ".. /file.md", + ".. ./file.md", + "notes/.. /etc/passwd", ] for path in attack_paths: