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

+1 -11
View File
@@ -61,7 +61,7 @@ from .projects import (
get_project_stats_handler, update_project_handler, duplicate_project_handler,
get_project_readme_handler, update_project_readme_handler,
lock_project_handler, unlock_project_handler,
load_project_handler, get_locked_project_handler,
get_locked_project_handler,
)
from .server import (
get_version_handler, get_statistics_handler,
@@ -1264,16 +1264,6 @@ async def project_locked(
})
@mcp.tool()
async def project_load(
path: Annotated[str, Field(description="Filesystem path to the .gns3 project file")],
) -> list[dict[str, Any]]:
"""Load a project from a file path on the server's filesystem."""
return await asyncio.to_thread(_run_handler_sync, load_project_handler, {
"path": path,
})
# ── Server info tools ─────────────────────────────────────────────────
-9
View File
@@ -176,15 +176,6 @@ def unlock_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"message": f"Project {project_id} unlocked", "project_id": project_id}
def load_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
path = params.get("path")
if not path:
return {"error": "path is required"}
conn = _get_connector(gns3_ctx)
result = conn.http_call("post", f"{conn.base_url}/projects/load", json_data={"path": path}).json()
return {"message": f"Project loaded from {path}", "project": result}
def get_locked_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id: