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
14 changes: 14 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,17 @@ On upgrade from older databases, existing plaintext secrets in SQLite are **migr
## Tests

Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring.

## Archive install limits (extensions and updates)

Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app updater extraction use `SafeZipExtractor` (`lib/core/security/safe_zip_extractor.dart`) with shared default limits:

| Limit | Default |
|-------|---------|
| Max compressed archive size | 100 MiB |
| Max total uncompressed size | 500 MiB |
| Max entries | 10 000 |
| Max single entry uncompressed size | 100 MiB |
| Max compression ratio (uncompressed ÷ compressed) | 100:1 |

Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`.
15 changes: 13 additions & 2 deletions lib/core/extensions/local_extension_installer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart';
import 'package:querya_desktop/core/market/marketplace_repository.dart';
import 'package:querya_desktop/core/security/archive_path_guard.dart';
import 'package:querya_desktop/core/security/safe_zip_extractor.dart';

/// Installs an extension package from a local `.zip` / `.qext` archive (issue #316).
///
Expand Down Expand Up @@ -43,7 +44,12 @@ class LocalExtensionInstaller {
}

onProgress?.call(0.1);
final bytes = await archiveFile.readAsBytes();
late final List<int> bytes;
try {
bytes = await SafeZipExtractor.readBoundedBytes(archiveFile);
} on SafeZipException catch (error) {
throw MarketplaceException(error.message);
}

if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) {
final actual = sha256.convert(bytes).toString().toLowerCase();
Expand All @@ -57,7 +63,12 @@ class LocalExtensionInstaller {
}

onProgress?.call(0.25);
final archive = ZipDecoder().decodeBytes(bytes);
late final Archive archive;
try {
archive = SafeZipExtractor.decodeBytes(bytes);
} on SafeZipException catch (error) {
throw MarketplaceException(error.message);
}
if (archive.isEmpty) {
throw MarketplaceException('Extension archive is empty.');
}
Expand Down
17 changes: 14 additions & 3 deletions lib/core/market/http_marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
import 'package:querya_desktop/core/security/archive_path_guard.dart';
import 'package:querya_desktop/core/security/safe_zip_extractor.dart';
import 'marketplace_download_policy.dart';
import 'marketplace_repository.dart';

Expand Down Expand Up @@ -170,7 +171,12 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
);
}

final bytes = await archiveFile.readAsBytes();
late final List<int> bytes;
try {
bytes = await SafeZipExtractor.readBoundedBytes(archiveFile);
} on SafeZipException catch (error) {
throw MarketplaceException(error.message);
}
final actualSha256 = sha256.convert(bytes).toString().toLowerCase();
if (actualSha256 != expectedSha256) {
throw MarketplaceException(
Expand All @@ -181,8 +187,13 @@ class HttpMarketplaceRepository implements MarketplaceRepository {

onProgress?.call(0.85);

// Step 3: Safe Archive Extraction (Preventing Path Traversal / Zip Bomb - Issue #242)
final archive = ZipDecoder().decodeBytes(bytes);
// Step 3: Safe Archive Extraction (path traversal + zip bomb limits)
final Archive archive;
try {
archive = SafeZipExtractor.decodeBytes(bytes);
} on SafeZipException catch (error) {
throw MarketplaceException(error.message);
}

final dir = await ExtensionPaths.extensionsDirectory();
final extDir = Directory(p.join(dir.path, manifest.id));
Expand Down
134 changes: 134 additions & 0 deletions lib/core/security/safe_zip_extractor.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import 'dart:io';

import 'package:archive/archive.dart';

/// Bounds for zip decode/extract to mitigate zip bombs and memory exhaustion.
class ZipDecodeLimits {
const ZipDecodeLimits({
required this.maxCompressedBytes,
required this.maxTotalUncompressedBytes,
required this.maxEntryCount,
required this.maxEntryUncompressedBytes,
required this.maxCompressionRatio,
});

final int maxCompressedBytes;
final int maxTotalUncompressedBytes;
final int maxEntryCount;
final int maxEntryUncompressedBytes;
final double maxCompressionRatio;

/// Default limits for marketplace, sideload, and updater archives.
static const ZipDecodeLimits standard = ZipDecodeLimits(
maxCompressedBytes: 100 * 1024 * 1024,
maxTotalUncompressedBytes: 500 * 1024 * 1024,
maxEntryCount: 10000,
maxEntryUncompressedBytes: 100 * 1024 * 1024,
maxCompressionRatio: 100,
);
}

/// Thrown when an archive exceeds [ZipDecodeLimits].
class SafeZipException implements Exception {
SafeZipException(this.message);

final String message;

@override
String toString() => 'SafeZipException: $message';
}

/// Bounded zip decode used by marketplace, sideload, and updater paths.
abstract final class SafeZipExtractor {
static Future<List<int>> readBoundedBytes(
File file, {
ZipDecodeLimits limits = ZipDecodeLimits.standard,
}) async {
final length = await file.length();
if (length > limits.maxCompressedBytes) {
throw SafeZipException(
'Archive exceeds maximum compressed size '
'(${limits.maxCompressedBytes} bytes).',
);
}
return file.readAsBytes();
}

static Archive decodeBytes(
List<int> bytes, {
ZipDecodeLimits limits = ZipDecodeLimits.standard,
}) {
if (bytes.length > limits.maxCompressedBytes) {
throw SafeZipException(
'Archive exceeds maximum compressed size '
'(${limits.maxCompressedBytes} bytes).',
);
}

final Archive archive;
try {
archive = ZipDecoder().decodeBytes(bytes);
} on Object catch (error) {
throw SafeZipException('Failed to decode zip archive: $error');
}

_validateArchive(
archive,
compressedBytes: bytes.length,
limits: limits,
);
return archive;
}

static Future<Archive> readAndDecodeFile(
File file, {
ZipDecodeLimits limits = ZipDecodeLimits.standard,
}) async {
final bytes = await readBoundedBytes(file, limits: limits);
return decodeBytes(bytes, limits: limits);
}

static void _validateArchive(
Archive archive, {
required int compressedBytes,
required ZipDecodeLimits limits,
}) {
if (archive.length > limits.maxEntryCount) {
throw SafeZipException(
'Archive contains too many entries (${archive.length}; '
'max ${limits.maxEntryCount}).',
);
}

var totalUncompressed = 0;
for (final entry in archive) {
if (!entry.isFile) continue;

final size = entry.size;
if (size > limits.maxEntryUncompressedBytes) {
throw SafeZipException(
'Archive entry "${entry.name}" exceeds maximum uncompressed size '
'($size bytes; max ${limits.maxEntryUncompressedBytes}).',
);
}

totalUncompressed += size;
if (totalUncompressed > limits.maxTotalUncompressedBytes) {
throw SafeZipException(
'Archive exceeds maximum total uncompressed size '
'(max ${limits.maxTotalUncompressedBytes} bytes).',
);
}
}

if (compressedBytes > 0 && totalUncompressed > 0) {
final ratio = totalUncompressed / compressedBytes;
if (ratio > limits.maxCompressionRatio) {
throw SafeZipException(
'Archive compression ratio is too high '
'(${ratio.toStringAsFixed(1)}:1; max ${limits.maxCompressionRatio}:1).',
);
}
}
}
}
9 changes: 7 additions & 2 deletions lib/core/updater/installers/update_install_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:archive/archive.dart';
import 'package:path/path.dart' as p;

import '../../security/archive_path_guard.dart';
import '../../security/safe_zip_extractor.dart';
import '../app_updater_service.dart';

/// Safely extracts a zip archive into [destinationDir].
Expand All @@ -16,8 +17,12 @@ Future<void> extractZipSecurely({
}
await destinationDir.create(recursive: true);

final bytes = await zipFile.readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
final Archive archive;
try {
archive = await SafeZipExtractor.readAndDecodeFile(zipFile);
} on SafeZipException catch (error) {
throw AppUpdaterException(error.message);
}
final root = p.normalize(destinationDir.path);

for (final entry in archive) {
Expand Down
136 changes: 136 additions & 0 deletions test/core/security/safe_zip_extractor_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import 'dart:convert';
import 'dart:io';

import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/security/safe_zip_extractor.dart';

const _tightLimits = ZipDecodeLimits(
maxCompressedBytes: 4096,
maxTotalUncompressedBytes: 8192,
maxEntryCount: 5,
maxEntryUncompressedBytes: 4096,
maxCompressionRatio: 10,
);

Archive _singleFileArchive(String name, List<int> content) {
return Archive()..addFile(ArchiveFile(name, content.length, content));
}

Future<File> _writeZip(Directory dir, Archive archive, String name) async {
final bytes = ZipEncoder().encode(archive);
final file = File(p.join(dir.path, name));
await file.writeAsBytes(bytes);
return file;
}

void main() {
group('SafeZipExtractor', () {
late Directory tempDir;

setUp(() async {
tempDir = await Directory.systemTemp.createTemp('querya_safe_zip_');
});

tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});

test('decodes a small valid archive', () {
final archive = _singleFileArchive('hello.txt', utf8.encode('hello'));
final zipBytes = ZipEncoder().encode(archive);

final decoded = SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits);
expect(decoded.length, 1);
expect(decoded.first.name, 'hello.txt');
});

test('rejects archives exceeding max compressed bytes', () async {
final file = File(p.join(tempDir.path, 'oversize.zip'));
await file.writeAsBytes(List<int>.filled(5000, 1));

expect(
() => SafeZipExtractor.readBoundedBytes(file, limits: _tightLimits),
throwsA(isA<SafeZipException>().having(
(e) => e.message,
'message',
contains('maximum compressed size'),
)),
);
});

test('rejects archives with too many entries', () {
final archive = Archive();
for (var i = 0; i < 6; i++) {
archive.addFile(ArchiveFile('file$i.txt', 1, [i]));
}
final zipBytes = ZipEncoder().encode(archive);

expect(
() => SafeZipExtractor.decodeBytes(zipBytes, limits: _tightLimits),
throwsA(isA<SafeZipException>().having(
(e) => e.message,
'message',
contains('too many entries'),
)),
);
});

test('rejects archives exceeding total uncompressed size', () {
const limits = ZipDecodeLimits(
maxCompressedBytes: 4096,
maxTotalUncompressedBytes: 6000,
maxEntryCount: 5,
maxEntryUncompressedBytes: 5000,
maxCompressionRatio: 100,
);
final archive = Archive()
..addFile(ArchiveFile('a.bin', 4000, List<int>.filled(4000, 1)))
..addFile(ArchiveFile('b.bin', 4000, List<int>.filled(4000, 2)));
final zipBytes = ZipEncoder().encode(archive);

expect(
() => SafeZipExtractor.decodeBytes(zipBytes, limits: limits),
throwsA(isA<SafeZipException>().having(
(e) => e.message,
'message',
contains('total uncompressed size'),
)),
);
});

test('rejects high compression ratio zip bombs', () {
const limits = ZipDecodeLimits(
maxCompressedBytes: 4096,
maxTotalUncompressedBytes: 8192,
maxEntryCount: 5,
maxEntryUncompressedBytes: 10000,
maxCompressionRatio: 10,
);
final payload = List<int>.filled(5000, 0);
final archive = _singleFileArchive('bomb.bin', payload);
final zipBytes = ZipEncoder().encode(archive);

expect(
() => SafeZipExtractor.decodeBytes(zipBytes, limits: limits),
throwsA(isA<SafeZipException>().having(
(e) => e.message,
'message',
contains('compression ratio'),
)),
);
});

test('readAndDecodeFile reads bounded archives from disk', () async {
final archive = _singleFileArchive('ok.txt', utf8.encode('ok'));
final zipFile = await _writeZip(tempDir, archive, 'ok.zip');

final decoded =
await SafeZipExtractor.readAndDecodeFile(zipFile, limits: _tightLimits);
expect(decoded.first.name, 'ok.txt');
});
});
}
Loading