From b797981a6512d850a04b8627f1ef03b242898d41 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 29 Jul 2026 18:48:31 +0200 Subject: [PATCH] test: add tests for unvalidated symlink creation in import_project --- gns3server/controller/import_project.py | 6 +- tests/controller/test_import_project.py | 98 ++++++++++++++++++------- 2 files changed, 76 insertions(+), 28 deletions(-) diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index 1cd402bad..c0e272f44 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -169,7 +169,11 @@ async def import_project( def _create_symbolic_links(zip_file, path): """ - Materialise symlink entries, refusing any target that escapes `path`. + Manually create symbolic links (if any) because ZipFile does not support it. + Refuse any target that escapes `path`. + + :param zip_file: ZipFile instance + :param path: project location """ path_root = os.path.realpath(path) + os.sep diff --git a/tests/controller/test_import_project.py b/tests/controller/test_import_project.py index b70a360ab..affbc9074 100644 --- a/tests/controller/test_import_project.py +++ b/tests/controller/test_import_project.py @@ -19,6 +19,8 @@ import os import uuid import json import zipfile +import pytest +import aiohttp from tests.utils import asyncio_patch, AsyncioMagicMock from unittest.mock import patch, MagicMock @@ -117,36 +119,48 @@ async def write_file(path, z): f.write(chunk) -async def test_import_project_containing_symlink(tmpdir, controller): +@pytest.fixture +def export_project_with_symlink(tmpdir, controller): + async def _export(symlink_target): + project = Project(controller=controller, name="test") + project.dump = MagicMock() - project = Project(controller=controller, name="test") - project.dump = MagicMock() - path = project.path + topology = { + "project_id": str(uuid.uuid4()), + "name": "test", + "auto_open": True, + "auto_start": True, + "topology": { + }, + "version": "2.0.0" + } + + with open(os.path.join(project.path, "project.gns3"), 'w+') as f: + json.dump(topology, f) + + os.makedirs(os.path.join(project.path, "vm1", "dynamips")) + symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink") + os.symlink(symlink_target, symlink_path) + + zip_path = str(tmpdir / "project.zip") + with aiozipstream.ZipFile() as z: + with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): + await export_project(z, project, str(tmpdir), include_images=False) + await write_file(zip_path, z) + + return zip_path + + return _export + + +async def test_import_project_containing_symlink(controller, export_project_with_symlink): + """ + Test importing a project containing a valid symlink (target inside the project directory). + """ project_id = str(uuid.uuid4()) - topology = { - "project_id": str(uuid.uuid4()), - "name": "test", - "auto_open": True, - "auto_start": True, - "topology": { - }, - "version": "2.0.0" - } - - with open(os.path.join(path, "project.gns3"), 'w+') as f: - json.dump(topology, f) - - os.makedirs(os.path.join(path, "vm1", "dynamips")) - symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink") - symlink_target = "/tmp/anywhere" - os.symlink(symlink_target, symlink_path) - - zip_path = str(tmpdir / "project.zip") - with aiozipstream.ZipFile() as z: - with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): - await export_project(z, project, str(tmpdir), include_images=False) - await write_file(zip_path, z) + symlink_target = "../symlink_target" + zip_path = await export_project_with_symlink(symlink_target) with open(zip_path, "rb") as f: project = await import_project(controller, project_id, f) @@ -158,6 +172,36 @@ async def test_import_project_containing_symlink(tmpdir, controller): assert os.readlink(symlink_path) == symlink_target +async def test_import_project_containing_absolute_symlink(controller, export_project_with_symlink): + """ + Test importing a project containing an absolute symlink. + This should fail because absolute symlinks are not allowed for security reasons. + """ + + project_id = str(uuid.uuid4()) + symlink_target = "/tmp/anywhere" + zip_path = await export_project_with_symlink(symlink_target) + + with pytest.raises(aiohttp.web.HTTPConflict): + with open(zip_path, "rb") as f: + await import_project(controller, project_id, f) + + +async def test_import_project_containing_escaping_symlink(controller, export_project_with_symlink): + """ + Test importing a project containing a symlink that escapes the project directory. + This should fail because symlinks that escape the project directory are not allowed for security reasons. + """ + + project_id = str(uuid.uuid4()) + symlink_target = "../../../../symlink_target" + zip_path = await export_project_with_symlink(symlink_target) + + with pytest.raises(aiohttp.web.HTTPConflict): + with open(zip_path, "rb") as f: + await import_project(controller, project_id, f) + + async def test_import_upgrade(tmpdir, controller): """ Topology made for previous GNS3 version are upgraded during the process