Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion lib/core/updater/installers/update_install_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,20 @@ Future<void> 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;
Expand All @@ -51,12 +64,28 @@ Future<void> 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<void> _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<File?> findAppImageInDirectory(Directory directory) async {
if (!await directory.exists()) return null;
Expand Down
83 changes: 83 additions & 0 deletions test/core/updater/update_platform_installer_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Directory> 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<int> 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<AppUpdaterException>()),
);
});

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<AppUpdaterException>()),
);
});
});

group('UpdatePlatformInstaller', () {
test('blocks install on snap with package manager hint', () async {
final installer = UpdatePlatformInstaller.forCurrentPlatform(
Expand Down
Loading