mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 14:01:15 +02:00
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:
11 files changed
+125
-28
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user