diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index 56fb70d9..0eed0f0b 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -41,7 +41,20 @@ Future extractZipSecurely({ ); } - if (entry.isFile) { + if (entry.isSymbolicLink) { + // The macOS bundle keeps its framework symlinks (`Versions/Current`); + // flattened to text files they fail `codesign --verify`. + final target = entry.symbolicLink!; + final resolved = p.normalize(p.join(p.dirname(targetPath), target)); + if (p.isAbsolute(target) || + !isArchiveExtractPathWithinRoot(root, resolved)) { + throw AppUpdaterException( + 'Security violation: symlink "$name" points outside the archive', + ); + } + await Directory(p.dirname(targetPath)).create(recursive: true); + await Link(targetPath).create(target); + } else if (entry.isFile) { final out = File(targetPath); await out.parent.create(recursive: true); final Uint8List bytes; @@ -51,12 +64,28 @@ Future extractZipSecurely({ throw AppUpdaterException(error.message); } await out.writeAsBytes(bytes); + await _applyUnixPermissions(out, entry.unixPermissions); } else { await Directory(targetPath).create(recursive: true); } } } +/// Restores the permission bits of an extracted file (POSIX only), so helper +/// binaries in an update stay executable. +Future _applyUnixPermissions(File file, int permissions) async { + if (Platform.isWindows || permissions == 0) return; + final result = await Process.run('chmod', [ + permissions.toRadixString(8), + file.path, + ]); + if (result.exitCode != 0) { + throw AppUpdaterException( + 'Could not set permissions on ${p.basename(file.path)}: ${result.stderr}', + ); + } +} + /// Finds the first `.AppImage` file inside [directory]. Future findAppImageInDirectory(Directory directory) async { if (!await directory.exists()) return null; diff --git a/test/core/updater/update_platform_installer_test.dart b/test/core/updater/update_platform_installer_test.dart index 2e1253e4..bd41ef0f 100644 --- a/test/core/updater/update_platform_installer_test.dart +++ b/test/core/updater/update_platform_installer_test.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -225,6 +227,87 @@ void main() { }); }); + group('extractZipSecurely symlinks and permissions (#1765)', () { + // Like `ditto -c -k`: the target is the content, the mode says "symlink". + ArchiveFile symlinkEntry(String name, String target) { + final bytes = utf8.encode(target); + return ArchiveFile(name, bytes.length, bytes)..mode = 0xa1ff; + } + + Future tempDir() async { + final temp = await Directory.systemTemp.createTemp('querya_zip_test_'); + addTearDown(() async { + if (await temp.exists()) await temp.delete(recursive: true); + }); + return temp; + } + + // The encoder always writes "made by MS-DOS"; `ditto` writes UNIX (3), + // which is what the decoder needs to treat an entry as a symlink. + List asUnixZip(Archive archive) { + final bytes = Uint8List.fromList(ZipEncoder().encode(archive)); + for (var i = 0; i + 5 < bytes.length; i++) { + if (bytes[i] == 0x50 && + bytes[i + 1] == 0x4b && + bytes[i + 2] == 0x01 && + bytes[i + 3] == 0x02) { + bytes[i + 5] = 3; + } + } + return bytes; + } + + test('keeps a symlink and the executable bit', () async { + final temp = await tempDir(); + final archive = Archive() + ..addFile(ArchiveFile('App/bin/tool', 2, [1, 2])..mode = 0x1ed) + ..addFile(symlinkEntry('App/bin/current', 'tool')); + final zipFile = File(p.join(temp.path, 'ok.zip')); + await zipFile.writeAsBytes(asUnixZip(archive)); + final out = Directory(p.join(temp.path, 'out')); + + await extractZipSecurely(zipFile: zipFile, destinationDir: out); + + final link = Link(p.join(out.path, 'App', 'bin', 'current')); + expect(await link.exists(), isTrue); + expect(await link.target(), 'tool'); + final mode = File(p.join(out.path, 'App', 'bin', 'tool')).statSync().mode; + expect(mode & 0x40, isNot(0)); + }, skip: Platform.isWindows); + + test('rejects a symlink pointing outside the root', () async { + final temp = await tempDir(); + final archive = Archive() + ..addFile(symlinkEntry('App/evil', '../../outside')); + final zipFile = File(p.join(temp.path, 'evil.zip')); + await zipFile.writeAsBytes(asUnixZip(archive)); + + expect( + () => extractZipSecurely( + zipFile: zipFile, + destinationDir: Directory(p.join(temp.path, 'out')), + ), + throwsA(isA()), + ); + }); + + test('rejects an absolute symlink target', () async { + final temp = await tempDir(); + final archive = Archive() + ..addFile(symlinkEntry('App/abs', '/etc/passwd')); + final zipFile = File(p.join(temp.path, 'abs.zip')); + await zipFile.writeAsBytes(asUnixZip(archive)); + + expect( + () => extractZipSecurely( + zipFile: zipFile, + destinationDir: Directory(p.join(temp.path, 'out')), + ), + throwsA(isA()), + ); + }); + }); + group('UpdatePlatformInstaller', () { test('blocks install on snap with package manager hint', () async { final installer = UpdatePlatformInstaller.forCurrentPlatform(