fix: never allow the projects directory to become a project directory

Loading a .gns3 placed directly in the projects root registered the
shared projects directory as the project path (load_project derives
the path from the file's parent directory). Deleting such an entry ran
rmtree on the projects directory itself, wiping every project until a
root-owned file stopped it, and left a zombie entry in the controller.

Three layers of protection:

- Controller.load_project() refuses a .gns3 whose parent directory is
  the projects directory; the normal subdirectory layout is unaffected
- the Project.path setter rejects the projects directory itself and
  its ancestors, closing the same hole for POST/PUT with an explicit
  path
- Project.delete() uses realpath + commonpath instead of commonprefix:
  entries whose path is the projects root are refused, and sibling
  directories sharing a string prefix (/srv/projects-evil vs
  /srv/projects) are no longer treated as inside the projects dir

Also removes the project_load MCP tool: loading by raw server
filesystem path is a footgun for automated clients; projects can still
be opened by project_id via the remaining tools.
This commit is contained in:
YueGuobin committed 2026-08-25 13:17:37 +08:00
1 parent 629bb9194f
commit 702fc1f6d9
11 files changed
+125 -28

No files matched your search

+48
View File
@@ -125,6 +125,54 @@ async def test_load_projects_skip_unexpected_errors(controller, projects_dir):
mock_load_project.assert_called_with(os.path.join(projects_dir, "broken_project", "broken.gns3"), load=False)
def _write_topology_file(path, project_id, name):
with open(path, "w+") as f:
json.dump(
{
"name": name,
"project_id": project_id,
"version": __version__,
"revision": 10,
"type": "topology",
"topology": {"computes": [], "drawings": [], "links": [], "nodes": []},
},
f,
)
@pytest.mark.asyncio
async def test_load_project_refuses_gns3_in_projects_directory(controller, projects_dir):
"""
A .gns3 placed directly in the projects directory must not be
loadable: its parent directory (the shared projects root) would become
the project directory, and deleting that project would wipe every
project on the controller.
"""
topology_file = os.path.join(projects_dir, "root-level.gns3")
_write_topology_file(topology_file, str(uuid.uuid4()), "root-level")
with pytest.raises(ControllerError):
await controller.load_project(topology_file)
assert not controller._projects
@pytest.mark.asyncio
async def test_load_project_from_own_subdirectory(controller, projects_dir):
"""
The normal layout — a .gns3 inside its own subdirectory — keeps
loading, with the subdirectory as the project directory.
"""
project_dir = os.path.join(projects_dir, "sub-project")
os.makedirs(project_dir)
topology_file = os.path.join(project_dir, "sub-project.gns3")
_write_topology_file(topology_file, str(uuid.uuid4()), "sub-project")
project = await controller.load_project(topology_file, load=False)
assert project.path == project_dir
def test_projects_directory_event_handler_filters_events(controller):
controller._notify_projects_directory_event = MagicMock()
+2 -1
View File
@@ -87,7 +87,8 @@ async def test_import_project_override(projects_dir, controller):
override the previous keeping the same project id & location
"""
tmpdir = Path(projects_dir)
tmpdir = Path(projects_dir) / "override-location"
tmpdir.mkdir(parents=True, exist_ok=True)
project_id = str(uuid.uuid4())
topology = {
"project_id": project_id,
+31
View File
@@ -741,6 +741,37 @@ async def test_delete(project):
assert not os.path.exists(project.path)
@pytest.mark.asyncio
async def test_delete_refuses_to_delete_projects_directory(project, projects_dir):
"""
A poisoned entry whose path is the projects directory itself (a .gns3
loaded directly from the projects root before the guard existed) must
not be deletable: rmtree would wipe every project on the controller.
"""
other_project = os.path.join(projects_dir, "another-project")
os.makedirs(other_project, exist_ok=True)
# Simulate the poisoned in-memory state directly: the path setter now
# rejects such an assignment, but a long-running server can still hold
# an entry created before the fix.
project._path = projects_dir
with pytest.raises(ControllerError):
await project.delete()
assert os.path.exists(other_project)
def test_path_setter_rejects_projects_directory(project, projects_dir):
"""
The projects directory itself must never become a project directory.
"""
with pytest.raises(ControllerForbiddenError):
project.path = projects_dir
assert project.path == os.path.join(projects_dir, project.id)
@pytest.mark.asyncio
async def test_delete_does_not_start_nodes(project):
"""
+4 -2
View File
@@ -192,12 +192,14 @@ async def test_open(controller, projects_dir):
"version": "2.0.0"
}
with open(os.path.join(projects_dir, "demo.gns3"), "w+") as f:
project_dir = os.path.join(projects_dir, "demo")
os.makedirs(project_dir)
with open(os.path.join(project_dir, "demo.gns3"), "w+") as f:
json.dump(simple_topology, f)
project = Project(name="demo",
project_id="64ba8408-afbf-4b66-9cdd-1fd854427478",
path=str(projects_dir),
path=project_dir,
controller=controller,
filename="demo.gns3",
status="closed")