From c75e1ba0be5133d9b6a8a65412b8941cc74fcddd Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 15 Jul 2026 20:45:33 +0200 Subject: [PATCH 1/8] Development on 2.2.61.dev1 --- gns3server/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gns3server/version.py b/gns3server/version.py index a384e3124..0df2dccb4 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.dev1" +__version_info__ = (2, 2, 61, 99) if "dev" in __version__: try: From 9562c9d828c5804033f6d3e36c6630a32f0d9a99 Mon Sep 17 00:00:00 2001 From: Iskander Nafikov Date: Tue, 28 Jul 2026 00:27:55 +0800 Subject: [PATCH 2/8] fix(qemu): remove trailing space from RNG object argument --- gns3server/compute/qemu/qemu_vm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index fa7757a62..c2c229ae2 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 From e2657febf0d79849fe36e024e23f960f2f456d2b Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 29 Jul 2026 17:23:28 +0200 Subject: [PATCH 3/8] fix(qemu): fix addition of QEMU RNG device causes interface names to change --- gns3server/compute/qemu/qemu_vm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index fa7757a62..e54454628 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -2480,6 +2480,8 @@ 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)]) + # network options must be first to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838 + command.extend((await self._network_options())) command.extend(self._bios_option()) command.extend(self._cdrom_option()) command.extend((await self._disk_options())) @@ -2497,7 +2499,6 @@ class QemuVM(BaseNode): elif self._console_type != "none": raise QemuError("Console type {} is unknown".format(self._console_type)) command.extend(self._monitor_options()) - command.extend((await self._network_options())) if self.on_close != "save_vm_state": await self._clear_save_vm_stated() else: From d9140d2f8c8dbbadbf0e32bdf1ffeeba75c0d369 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 29 Jul 2026 17:47:03 +0200 Subject: [PATCH 4/8] fix(qemu): move the bios options after the network ones --- gns3server/compute/qemu/qemu_vm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index e54454628..3353048f8 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -2480,9 +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)]) - # network options must be first to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838 - command.extend((await self._network_options())) - command.extend(self._bios_option()) command.extend(self._cdrom_option()) command.extend((await self._disk_options())) command.extend(self._linux_boot_options()) @@ -2499,6 +2496,9 @@ class QemuVM(BaseNode): elif self._console_type != "none": 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: From a225622a6edc5e122b524f4a58255743b62ab72f Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 29 Jul 2026 18:10:52 +0200 Subject: [PATCH 5/8] fix(import): unvalidated symlink creation in import_project --- gns3server/controller/import_project.py | 50 ++++++++++++++++++------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index a1912208f..1cd402bad 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -166,24 +166,48 @@ 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. - - :param zip_file: ZipFile instance - :param path: project location + Materialise symlink entries, refusing any target that escapes `path`. """ + 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): """ From b797981a6512d850a04b8627f1ef03b242898d41 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 29 Jul 2026 18:48:31 +0200 Subject: [PATCH 6/8] 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 From 5485eef3ad5b1590f1a0fd442daf1a496cf8f8c6 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 29 Jul 2026 18:52:23 +0200 Subject: [PATCH 7/8] Sync appliances --- gns3server/appliances/armbian.gns3a | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 gns3server/appliances/armbian.gns3a 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" + } + } + ] +} From da2942c13978d66f78a874bdc9f3c3b82d179a4f Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 30 Jul 2026 12:53:08 +0200 Subject: [PATCH 8/8] Release v2.2.61 --- CHANGELOG | 9 +++++++++ gns3server/crash_report.py | 2 +- gns3server/version.py | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) 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/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 0df2dccb4..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.61.dev1" -__version_info__ = (2, 2, 61, 99) +__version__ = "2.2.61" +__version_info__ = (2, 2, 61, 0) if "dev" in __version__: try: