From 3a7e160a556a377290bb2377a985e2cbcdfa1e14 Mon Sep 17 00:00:00 2001 From: siye566 <114060217+siye566@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:04:06 +0800 Subject: [PATCH] fix(paths): reject superscript Windows device names (#6148) --- README.md | 5 +++++ backend/packages/harness/deerflow/utils/AGENTS.md | 8 ++++++++ backend/packages/harness/deerflow/utils/host_paths.py | 4 +++- backend/tests/test_sandbox_tools_security.py | 8 ++++++++ backend/tests/test_skill_storage_support_files.py | 2 +- backend/tests/test_uploads_manager.py | 4 ++-- 6 files changed, 27 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0274a17752..23c2a32b12 100644 --- a/README.md +++ b/README.md @@ -1902,6 +1902,11 @@ also fails, for example because of a Windows sharing violation. Once the file is published, a temporary-file cleanup failure is logged without failing the upload; hidden staging files are left for the startup sweep. +Uploads, new skill support files, and new local sandbox paths reject Windows +reserved device names on every platform, including `COM¹`, `LPT²` and names with +extensions such as `com³.txt`. Rename these files before creating or uploading +them so the same file tree remains usable on Windows. + Uploaded filenames matching `.upload-*.part` are rejected because that pattern is reserved for temporary staging files. Rename such a file before uploading it. The restriction includes Windows aliases with trailing dots or spaces and diff --git a/backend/packages/harness/deerflow/utils/AGENTS.md b/backend/packages/harness/deerflow/utils/AGENTS.md index bc5d586b12..2389ab5176 100644 --- a/backend/packages/harness/deerflow/utils/AGENTS.md +++ b/backend/packages/harness/deerflow/utils/AGENTS.md @@ -31,3 +31,11 @@ characters, and the original file bytes. boundary for artifacts and project documents. Keep platform aliases such as Windows' `image/svg` aligned with their standard active type (`image/svg+xml`), and preserve the generic `+xml` rule. + +### Host Path Portability + +`host_paths.py` rejects Windows device names for host-visible creation paths on +every platform, including the `COM`/`LPT` aliases with superscript ¹, ² and ³. +Do not normalize arbitrary Unicode digits into device numbers: names such as +`COM⁴.txt` and `COM¹notes.txt` are ordinary portable names. Read/removal callers +retain their existing portability exemptions. diff --git a/backend/packages/harness/deerflow/utils/host_paths.py b/backend/packages/harness/deerflow/utils/host_paths.py index 9550446a7d..a30aea01a2 100644 --- a/backend/packages/harness/deerflow/utils/host_paths.py +++ b/backend/packages/harness/deerflow/utils/host_paths.py @@ -2,7 +2,9 @@ from __future__ import annotations -_WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in range(1, 10)} | {f"LPT{i}" for i in range(1, 10)}) +# Windows also treats the ISO-8859-1 superscript digits ¹, ² and ³ as device numbers. +# https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file +_WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in "123456789¹²³"} | {f"LPT{i}" for i in "123456789¹²³"}) def windows_incompatible_segment(segment: str) -> str | None: diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 22649193cd..8f7e3583e7 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -503,6 +503,14 @@ def test_validate_local_tool_path_write_rejects_new_nonportable_names() -> None: validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/uploads/../../etc/passwd", _THREAD_DATA, read_only=False) +@pytest.mark.parametrize("name", ["COM¹", "com².txt", "COM³.log", "LPT¹", "lpt².md", "LPT³.pdf"]) +def test_validate_local_tool_path_rejects_new_superscript_device_names(name: str) -> None: + path = f"{VIRTUAL_PATH_PREFIX}/workspace/{name}" + with pytest.raises(PermissionError, match="Access denied"): + validate_local_tool_path(path, _THREAD_DATA, read_only=False) + validate_local_tool_path(path, _THREAD_DATA, read_only=True) + + def test_validate_local_tool_path_rejects_traversal_in_user_data() -> None: """Path traversal via .. in user-data paths must be rejected.""" with pytest.raises(PermissionError, match="path traversal"): diff --git a/backend/tests/test_skill_storage_support_files.py b/backend/tests/test_skill_storage_support_files.py index 09a3eca30f..95305b908a 100644 --- a/backend/tests/test_skill_storage_support_files.py +++ b/backend/tests/test_skill_storage_support_files.py @@ -74,7 +74,7 @@ def test_ensure_safe_support_path_requires_a_filename_below_the_subdir(storage, assert storage.ensure_safe_support_path("demo-skill", "assets/logo.png") == (skill_dir / "assets" / "logo.png").resolve() -@pytest.mark.parametrize("path", ["assets/CON", "assets/nul.txt", "assets/logo.png.", "assets/notes.md "]) +@pytest.mark.parametrize("path", ["assets/CON", "assets/nul.txt", "assets/com¹.txt", "assets/LPT²", "assets/LPT³/icon.png", "assets/logo.png.", "assets/notes.md "]) def test_ensure_safe_support_path_rejects_windows_incompatible_names(storage, skill_dir, path): with pytest.raises(ValueError, match="not portable to Windows"): storage.ensure_safe_support_path("demo-skill", path) diff --git a/backend/tests/test_uploads_manager.py b/backend/tests/test_uploads_manager.py index 1febee1ba4..861520512a 100644 --- a/backend/tests/test_uploads_manager.py +++ b/backend/tests/test_uploads_manager.py @@ -94,13 +94,13 @@ class TestNormalizeFilename: @pytest.mark.parametrize( "filename", - ["CON", "con.txt", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1", "LPT9", "file.txt.", "file.txt ", "a.", "folder/CON"], + ["CON", "con.txt", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1", "LPT9", "COM¹", "com².txt", "COM³.log", "LPT¹", "lpt².md", "LPT³.pdf", "file.txt.", "file.txt ", "a.", "folder/CON"], ) def test_rejects_windows_incompatible_names(self, filename): with pytest.raises(ValueError, match="not portable to Windows"): normalize_filename(filename) - @pytest.mark.parametrize("filename", ["report.pdf", "contour.txt", "console.log", "COM0", "COM10", ".gitignore"]) + @pytest.mark.parametrize("filename", ["report.pdf", "contour.txt", "console.log", "COM0", "COM10", "COM⁴.txt", "LPT⁵", "COM¹notes.txt", "LPT²0.md", ".gitignore"]) def test_allows_portable_names(self, filename): assert normalize_filename(filename) == filename