mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 15:36:48 +00:00
[security] fix(upload): reject symlinked upload destinations (#2623)
* fix: reject symlinked upload destinations * test: harden upload destination checks * fix: address PR feedback for #2623 * test: cover safe upload re-uploads * fix: preserve upload limit checks after rebase * fix(upload): stream safe HTTP upload writes
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import os
|
||||
import stat
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -428,6 +429,105 @@ def test_upload_files_rejects_dotdot_and_dot_filenames(tmp_path):
|
||||
assert [f.name for f in thread_uploads_dir.iterdir()] == ["passwd"]
|
||||
|
||||
|
||||
def test_upload_files_rejects_preexisting_symlink_destination(tmp_path):
|
||||
thread_uploads_dir = tmp_path / "uploads"
|
||||
thread_uploads_dir.mkdir(parents=True)
|
||||
outside_file = tmp_path / "outside.txt"
|
||||
outside_file.write_text("protected", encoding="utf-8")
|
||||
(thread_uploads_dir / "victim.txt").symlink_to(outside_file)
|
||||
|
||||
provider = MagicMock()
|
||||
provider.uses_thread_data_mounts = True
|
||||
|
||||
with (
|
||||
patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "get_sandbox_provider", return_value=provider),
|
||||
):
|
||||
file = UploadFile(filename="victim.txt", file=BytesIO(b"attacker upload"))
|
||||
result = asyncio.run(uploads.upload_files("thread-local", files=[file]))
|
||||
|
||||
assert result.success is False
|
||||
assert result.files == []
|
||||
assert result.skipped_files == ["victim.txt"]
|
||||
assert "skipped 1 unsafe file" in result.message
|
||||
assert outside_file.read_text(encoding="utf-8") == "protected"
|
||||
assert (thread_uploads_dir / "victim.txt").is_symlink()
|
||||
|
||||
|
||||
def test_upload_files_rejects_dangling_symlink_destination(tmp_path):
|
||||
thread_uploads_dir = tmp_path / "uploads"
|
||||
thread_uploads_dir.mkdir(parents=True)
|
||||
missing_target = tmp_path / "missing-target.txt"
|
||||
(thread_uploads_dir / "victim.txt").symlink_to(missing_target)
|
||||
|
||||
provider = MagicMock()
|
||||
provider.uses_thread_data_mounts = True
|
||||
|
||||
with (
|
||||
patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "get_sandbox_provider", return_value=provider),
|
||||
):
|
||||
file = UploadFile(filename="victim.txt", file=BytesIO(b"attacker upload"))
|
||||
result = asyncio.run(uploads.upload_files("thread-local", files=[file]))
|
||||
|
||||
assert result.success is False
|
||||
assert result.files == []
|
||||
assert result.skipped_files == ["victim.txt"]
|
||||
assert not missing_target.exists()
|
||||
assert (thread_uploads_dir / "victim.txt").is_symlink()
|
||||
|
||||
|
||||
def test_upload_files_rejects_hardlinked_destination_without_truncating(tmp_path):
|
||||
thread_uploads_dir = tmp_path / "uploads"
|
||||
thread_uploads_dir.mkdir(parents=True)
|
||||
outside_file = tmp_path / "outside.txt"
|
||||
outside_file.write_text("protected", encoding="utf-8")
|
||||
os.link(outside_file, thread_uploads_dir / "victim.txt")
|
||||
|
||||
provider = MagicMock()
|
||||
provider.uses_thread_data_mounts = True
|
||||
|
||||
with (
|
||||
patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "get_sandbox_provider", return_value=provider),
|
||||
):
|
||||
file = UploadFile(filename="victim.txt", file=BytesIO(b"attacker upload"))
|
||||
result = asyncio.run(uploads.upload_files("thread-local", files=[file]))
|
||||
|
||||
assert result.success is False
|
||||
assert result.files == []
|
||||
assert result.skipped_files == ["victim.txt"]
|
||||
assert outside_file.read_text(encoding="utf-8") == "protected"
|
||||
assert (thread_uploads_dir / "victim.txt").read_text(encoding="utf-8") == "protected"
|
||||
|
||||
|
||||
def test_upload_files_overwrites_existing_regular_file(tmp_path):
|
||||
thread_uploads_dir = tmp_path / "uploads"
|
||||
thread_uploads_dir.mkdir(parents=True)
|
||||
existing_file = thread_uploads_dir / "notes.txt"
|
||||
existing_file.write_bytes(b"old upload")
|
||||
assert existing_file.stat().st_nlink == 1
|
||||
|
||||
provider = MagicMock()
|
||||
provider.uses_thread_data_mounts = True
|
||||
|
||||
with (
|
||||
patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "ensure_uploads_dir", return_value=thread_uploads_dir),
|
||||
patch.object(uploads, "get_sandbox_provider", return_value=provider),
|
||||
):
|
||||
file = UploadFile(filename="notes.txt", file=BytesIO(b"new upload"))
|
||||
result = asyncio.run(uploads.upload_files("thread-local", files=[file]))
|
||||
|
||||
assert result.success is True
|
||||
assert [file_info["filename"] for file_info in result.files] == ["notes.txt"]
|
||||
assert existing_file.read_bytes() == b"new upload"
|
||||
assert existing_file.stat().st_nlink == 1
|
||||
|
||||
|
||||
def test_delete_uploaded_file_removes_generated_markdown_companion(tmp_path):
|
||||
thread_uploads_dir = tmp_path / "uploads"
|
||||
thread_uploads_dir.mkdir(parents=True)
|
||||
|
||||
Reference in New Issue
Block a user