diff --git a/CHANGELOG b/CHANGELOG index 02d3d14f9..3469a123a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,14 @@ # Change Log +## 2.2.61 30/07/2026 + +* Sync appliances +* fix(import): unvalidated symlink creation in import_project +* fix(qemu): fix addition of QEMU RNG device causes interface names to change +* fix(qemu): remove trailing space from RNG object argument +* fix: do not start nodes when deleting a project +* fix: correct always-true state check in DockerVM.stop() + ## 2.2.60 15/07/2026 * Sync appliances diff --git a/gns3server/appliances/armbian.gns3a b/gns3server/appliances/armbian.gns3a new file mode 100644 index 000000000..094f2bd35 --- /dev/null +++ b/gns3server/appliances/armbian.gns3a @@ -0,0 +1,59 @@ +{ + "appliance_id": "b3b90fde-143a-4129-8031-ccbba73c5e02", + "name": "armbian", + "category": "guest", + "description": "A highly optimized base operating system specialized for single board computers (SBCs) and its extensive build framework.", + "vendor_name": "The Armbian team", + "vendor_url": "https://armbian.com/", + "documentation_url": "https://docs.armbian.com/", + "product_name": "Armbian UEFI x86", + "product_url": "https://armbian.com/boards/uefi-x86", + "registry_version": 4, + "status": "stable", + "maintainer": "GNS3 Team", + "maintainer_email": "developers@gns3.net", + "usage": "By first login you create root password and new sudo user.\n\nBoot disk from UEFI shell, type: FS0:EFI\\BOOT\\BOOTX64 and press ", + "port_name_format": "Ethernet{0}", + "qemu": { + "adapter_type": "virtio-net-pci", + "adapters": 2, + "ram": 256, + "hda_disk_interface": "virtio", + "arch": "x86_64", + "console_type": "spice+agent", + "uefi": false, + "boot_priority": "c", + "kvm": "require", + "options": "-nographic" + }, + "images": [ + { + "filename": "OVMF-edk2-stable202305.fd", + "version": "stable202305", + "md5sum": "6c4cf1519fec4a4b95525d9ae562963a", + "filesize": 4194304, + "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", + "direct_download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/OVMF-edk2-stable202305.fd.zip/download", + "compression": "zip" + }, + { + "filename": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2", + "version": "Armbian 26.5.1 Minimal (CLI)", + "md5sum": "7f4c915668718d6135406de5a6c4fc30", + "filesize": 877920512, + "download_url": "https://armbian.com/boards/uefi-x86", + "direct_download_url": "https://armbian.atomonetworks.com/dl/uefi-x86/archive/Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2.xz", + "compression": "xz" + } + + ], + "versions": [ + { + "name": "Armbian 26.5.1 Minimal (CLI)", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2" + } + } + ] +} diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index fa7757a62..9c795968f 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -2107,7 +2107,7 @@ class QemuVM(BaseNode): options.extend(["-drive", "if=pflash,format=raw,file={}".format(ovmf_vars_node_path)]) # edk2 firmware requires a Random Number Generator (RNG) device in order to turn network adapters on - options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0 "]) + options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0"]) options.extend(["-device", "virtio-rng-pci,rng=rng0"]) return options @@ -2480,7 +2480,6 @@ class QemuVM(BaseNode): elif sys.platform.startswith("win") or sys.platform.startswith("darwin"): command.extend(["-enable-hax"]) command.extend(["-boot", "order={}".format(self._boot_priority)]) - command.extend(self._bios_option()) command.extend(self._cdrom_option()) command.extend((await self._disk_options())) command.extend(self._linux_boot_options()) @@ -2498,6 +2497,8 @@ class QemuVM(BaseNode): raise QemuError("Console type {} is unknown".format(self._console_type)) command.extend(self._monitor_options()) command.extend((await self._network_options())) + # bios options must be last to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838 + command.extend(self._bios_option()) if self.on_close != "save_vm_state": await self._clear_save_vm_stated() else: diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index a1912208f..c0e272f44 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -166,24 +166,52 @@ async def import_project( project = await controller.load_project(dot_gns3_path, load=False) return project + def _create_symbolic_links(zip_file, 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 for zip_info in zip_file.infolist(): - if stat.S_ISLNK(zip_info.external_attr >> 16): - symlink_target = zip_file.read(zip_info.filename).decode() - symlink_path = os.path.join(path, zip_info.filename) - try: - # remove the regular file and replace it by a symbolic link - os.remove(symlink_path) - os.symlink(symlink_target, symlink_path) - except OSError as e: - raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}") + if not stat.S_ISLNK(zip_info.external_attr >> 16): + continue + symlink_target = zip_file.read(zip_info.filename).decode() + symlink_path = os.path.join(path, zip_info.filename) + + # 1. Reject absolute targets outright. + if os.path.isabs(symlink_target): + raise aiohttp.web.HTTPConflict( + text=f"Symlink {zip_info.filename!r} has absolute target {symlink_target!r}, refusing" + ) + + # 2. Reject paths where the entry name itself escapes (defence in depth; + # extractall normally would already have caught this). + member_abs = os.path.realpath(symlink_path) + if not (member_abs + os.sep).startswith(path_root) and member_abs + os.sep != path_root: + raise aiohttp.web.HTTPConflict( + text=f"Symlink entry {zip_info.filename!r} escapes project dir, refusing" + ) + + # 3. Resolve the symlink target relative to the entry's own parent + # directory and verify the resolved real path stays inside `path`. + link_dir = os.path.realpath(os.path.dirname(symlink_path)) + resolved_target = os.path.realpath(os.path.join(link_dir, symlink_target)) + if not (resolved_target + os.sep).startswith(path_root) and resolved_target + os.sep != path_root: + raise aiohttp.web.HTTPConflict( + text=f"Symlink {zip_info.filename!r} -> {symlink_target!r} escapes project dir, refusing" + ) + + try: + os.remove(symlink_path) + os.symlink(symlink_target, symlink_path) + except OSError as e: + raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}") + def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False): """ diff --git a/gns3server/crash_report.py b/gns3server/crash_report.py index 5b3250a0a..ac2b3bb57 100644 --- a/gns3server/crash_report.py +++ b/gns3server/crash_report.py @@ -57,7 +57,7 @@ class CrashReport: Report crash to a third party service """ - DSN = "https://deee946d000cb5548acebb1cd7c1a1d3@o19455.ingest.us.sentry.io/38482" + DSN = "https://89ba0bf8d773c2c95518bfb696d6d86f@o19455.ingest.us.sentry.io/38482" _instance = None def __init__(self): diff --git a/gns3server/version.py b/gns3server/version.py index a384e3124..9d8b95878 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -23,8 +23,8 @@ # or negative for a release candidate or beta (after the base version # number has been incremented) -__version__ = "2.2.60" -__version_info__ = (2, 2, 60, 0) +__version__ = "2.2.61" +__version_info__ = (2, 2, 61, 0) if "dev" in __version__: try: 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