Skip to content

Commit a77b51a

Browse files
jope-bmclaude
andauthored
fix(core): allow double-dot filenames while still blocking path traversal (#673)
Signed-off-by: Joe P <joe@basicmemory.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1a6a655 commit a77b51a

4 files changed

Lines changed: 81 additions & 4 deletions

File tree

src/basic_memory/file_utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,11 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
447447
# compress multiple, repeated replacements
448448
text = re.sub(f"{re.escape(replacement)}+", replacement, text)
449449

450+
# Strip trailing periods — they cause "hi-everyone..md" double-dot filenames
451+
# when ".md" is appended, which triggers path traversal false positives.
452+
# Trailing periods are also invalid on Windows filesystems.
453+
text = text.strip(".")
454+
450455
return text.strip(replacement)
451456

452457

src/basic_memory/utils.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -503,12 +503,23 @@ def valid_project_path_value(path: str):
503503
if not path:
504504
return True
505505

506-
# Check for obvious path traversal patterns first
507-
if ".." in path or "~" in path:
506+
# Check for tilde (home directory expansion)
507+
if "~" in path:
508508
return False
509509

510-
# Check for Windows-style path traversal (even on Unix systems)
511-
if "\\.." in path or path.startswith("\\"):
510+
# Check for ".." as a path segment (path traversal), not as a substring.
511+
# Filenames like "hi-everyone..md" are legitimate and must not be blocked.
512+
# Also block segments like ".. " and ".. ." because Windows normalizes
513+
# trailing dots and spaces away, making them equivalent to "..".
514+
segments = path.replace("\\", "/").split("/")
515+
if any(
516+
seg == ".." or (len(seg) > 2 and seg[:2] == ".." and all(c in ". " for c in seg[2:]))
517+
for seg in segments
518+
):
519+
return False
520+
521+
# Check for Windows-style leading backslash
522+
if path.startswith("\\"):
512523
return False
513524

514525
# Block absolute paths (Unix-style starting with / or Windows-style with drive letters)

tests/utils/test_file_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ def test_sanitize_for_filename_removes_invalid_characters():
198198
assert char not in sanitized_text
199199

200200

201+
def test_sanitize_for_filename_strips_trailing_periods():
202+
"""Trailing periods cause double-dot filenames like 'hi-everyone..md'.
203+
204+
This was a production bug where title "Hi everyone." produced file path
205+
"hi-everyone..md" which failed path traversal validation.
206+
"""
207+
assert sanitize_for_filename("Hi everyone.") == "Hi everyone"
208+
assert sanitize_for_filename("test...") == "test"
209+
assert sanitize_for_filename(".hidden") == "hidden"
210+
assert sanitize_for_filename("...dots...") == "dots"
211+
assert sanitize_for_filename("normal title") == "normal title"
212+
213+
201214
@pytest.mark.parametrize(
202215
"input_directory,expected",
203216
[

tests/utils/test_validate_project_path.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,54 @@ def test_absolute_paths(self, tmp_path):
191191
assert not result, f"Absolute path '{path}' should be blocked"
192192

193193

194+
class TestValidateProjectPathDoubleDotInFilename:
195+
"""Test that filenames containing '..' as part of the name are allowed."""
196+
197+
def test_double_dot_in_filename_allowed(self, tmp_path):
198+
"""Filenames like 'hi-everyone..md' should NOT be blocked.
199+
200+
This was a production bug: a title ending with a period (e.g. "Hi everyone.")
201+
produced a file path like "hi-everyone..md" which the old substring check
202+
('..' in path) incorrectly flagged as path traversal.
203+
"""
204+
project_path = tmp_path / "project"
205+
project_path.mkdir()
206+
207+
safe_paths_with_dots = [
208+
"hi-everyone..md",
209+
"notes/hi-everyone..md",
210+
"version-2..0.md",
211+
"file...name.md",
212+
"docs/report..final.txt",
213+
]
214+
215+
for path in safe_paths_with_dots:
216+
assert validate_project_path(path, project_path), (
217+
f"Path '{path}' with '..' in filename should be allowed"
218+
)
219+
220+
def test_actual_traversal_still_blocked(self, tmp_path):
221+
"""Ensure '..' as a path segment is still blocked."""
222+
project_path = tmp_path / "project"
223+
project_path.mkdir()
224+
225+
attack_paths = [
226+
"../file.md",
227+
"notes/../../etc/passwd",
228+
"foo/../../../bar",
229+
"..\\Windows\\System32",
230+
# Windows normalizes trailing dots/spaces to ".."
231+
".. /file.md",
232+
".. ./file.md",
233+
"notes/.. /etc/passwd",
234+
]
235+
236+
for path in attack_paths:
237+
assert not validate_project_path(path, project_path), (
238+
f"Traversal path '{path}' should still be blocked"
239+
)
240+
241+
194242
class TestValidateProjectPathEdgeCases:
195243
"""Test edge cases and error conditions."""
196244

0 commit comments

Comments
 (0)