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

+11
View File
@@ -745,6 +745,17 @@ class Controller:
if not os.path.exists(path):
raise ControllerError(f"'{path}' does not exist on the controller")
# A .gns3 file must live in its own directory: the project path is
# the file's parent directory. A file placed directly in the
# projects directory would register the shared projects root as the
# project directory, and deleting that project would wipe every
# project on the controller.
projects_path = os.path.realpath(self.projects_directory())
if os.path.realpath(os.path.dirname(path)) == projects_path:
raise ControllerError(
f"'{path}' cannot be loaded: the .gns3 file must be in its own subdirectory of '{projects_path}'"
)
topo_data = load_topology(path)
topo_data.pop("topology")
topo_data.pop("version")
+22 -2
View File
@@ -471,6 +471,18 @@ class Project:
@path.setter
def path(self, path):
check_path_allowed(path)
# The projects directory itself (or one of its ancestors) must
# never become a project directory: deleting such a "project"
# would wipe every project on the controller.
real_path = os.path.realpath(path)
real_projects_path = os.path.realpath(get_default_project_directory())
if os.path.commonpath([real_path, real_projects_path]) == real_path:
raise ControllerForbiddenError(
f"The project directory cannot be '{path}': it must be a subdirectory "
f"of '{real_projects_path}', not the projects directory itself or one of its parents"
)
try:
os.makedirs(path, exist_ok=True)
except OSError as e:
@@ -1608,11 +1620,19 @@ class Project:
await self._cleanup_web_wireshark_container()
try:
project_directory = get_default_project_directory()
if not os.path.commonprefix([project_directory, self.path]) == project_directory:
project_directory = os.path.realpath(get_default_project_directory())
path = os.path.realpath(self.path)
if os.path.commonpath([path, project_directory]) != project_directory:
raise ControllerError(
f"Project '{self._name}' cannot be deleted because it is not in the default project directory: '{project_directory}'"
)
if path == project_directory:
# A poisoned or hand-crafted entry whose path is the
# projects root itself must never be deletable: rmtree
# would wipe every project on the controller.
raise ControllerError(
f"Project '{self._name}' cannot be deleted because its directory is the projects directory itself: '{path}'"
)
shutil.rmtree(self.path)
except OSError as e:
raise ControllerError(f"Cannot delete project directory {self.path}: {str(e)}")