From ef24a6d347e12a432e822d73af2fadaf9d81a9b7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 19:23:34 +0300 Subject: [PATCH] perf(marketplace): stream SHA256 and file-decode zip archives Hash archives with sha256HexOfFile (openRead stream) and decode via InputFileStream so marketplace/sideload no longer keep a full byte buffer plus ZipDecoder output. Clear entry content after write. Closes #418 --- docs/security.md | 2 + .../extensions/local_extension_installer.dart | 12 +++--- .../market/http_marketplace_repository.dart | 18 +++++---- lib/core/security/safe_zip_extractor.dart | 40 +++++++++++++++++-- .../security/safe_zip_extractor_test.dart | 13 ++++++ 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/docs/security.md b/docs/security.md index 8480bf26..0af80608 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,6 +37,8 @@ Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app u Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`. +SHA-256 verification for marketplace/sideload streams the file (`sha256.bind(file.openRead())`, same helper as the updater) instead of hashing a full in-memory copy. Zip decode uses a file stream (`InputFileStream`) so the compressed payload is not held as a separate `List` alongside the decoded archive; entry contents are cleared after each write. + ## Extension driver OS sandbox Process-sandbox database drivers launch inside OS-level isolation when available: diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index 3296763f..ee273452 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; -import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; @@ -12,6 +11,7 @@ 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'; +import 'package:querya_desktop/core/updater/sha256_checksums.dart'; /// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). /// @@ -44,15 +44,14 @@ class LocalExtensionInstaller { } onProgress?.call(0.1); - late final List bytes; try { - bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + await SafeZipExtractor.ensureCompressedSizeAllowed(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { - final actual = sha256.convert(bytes).toString().toLowerCase(); + final actual = (await sha256HexOfFile(archiveFile)).toLowerCase(); final expected = expectedSha256.trim().toLowerCase(); if (actual != expected) { throw MarketplaceException( @@ -65,7 +64,7 @@ class LocalExtensionInstaller { onProgress?.call(0.25); late final Archive archive; try { - archive = SafeZipExtractor.decodeBytes(bytes); + archive = await SafeZipExtractor.readAndDecodeFile(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } @@ -245,7 +244,8 @@ class LocalExtensionInstaller { if (file.isFile) { final outFile = File(targetPath); await outFile.parent.create(recursive: true); - await outFile.writeAsBytes(file.content as List); + await outFile.writeAsBytes(file.content); + file.clear(); } else { await Directory(targetPath).create(recursive: true); } diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 11578fb7..e49694fb 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; -import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; @@ -14,6 +13,7 @@ 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 'package:querya_desktop/core/updater/sha256_checksums.dart'; import 'marketplace_download_policy.dart'; import 'marketplace_repository.dart'; @@ -162,7 +162,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ); try { - // Step 2: SHA-256 Integrity Verification (Critical Security Check) + // Step 2: SHA-256 Integrity Verification (stream — no full-buffer hash) final expectedSha256 = manifest.sha256Checksum?.trim().toLowerCase(); if (expectedSha256 == null || expectedSha256.isEmpty) { throw MarketplaceException( @@ -171,13 +171,13 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ); } - late final List bytes; try { - bytes = await SafeZipExtractor.readBoundedBytes(archiveFile); + await SafeZipExtractor.ensureCompressedSizeAllowed(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } - final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); + + final actualSha256 = (await sha256HexOfFile(archiveFile)).toLowerCase(); if (actualSha256 != expectedSha256) { throw MarketplaceException( 'SHA256 checksum mismatch for "${manifest.id}". ' @@ -187,10 +187,10 @@ class HttpMarketplaceRepository implements MarketplaceRepository { onProgress?.call(0.85); - // Step 3: Safe Archive Extraction (path traversal + zip bomb limits) + // Step 3: Safe Archive Extraction (file-stream decode + path/zip-bomb limits) final Archive archive; try { - archive = SafeZipExtractor.decodeBytes(bytes); + archive = await SafeZipExtractor.readAndDecodeFile(archiveFile); } on SafeZipException catch (error) { throw MarketplaceException(error.message); } @@ -218,7 +218,9 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (file.isFile) { final outFile = File(targetPath); await outFile.create(recursive: true); - await outFile.writeAsBytes(file.content as List); + final bytes = file.content; + await outFile.writeAsBytes(bytes); + file.clear(); } else { await Directory(targetPath).create(recursive: true); } diff --git a/lib/core/security/safe_zip_extractor.dart b/lib/core/security/safe_zip_extractor.dart index 0ff52fad..17d05759 100644 --- a/lib/core/security/safe_zip_extractor.dart +++ b/lib/core/security/safe_zip_extractor.dart @@ -40,7 +40,8 @@ class SafeZipException implements Exception { /// Bounded zip decode used by marketplace, sideload, and updater paths. abstract final class SafeZipExtractor { - static Future> readBoundedBytes( + /// Ensures [file] is within [limits.maxCompressedBytes] before reading. + static Future ensureCompressedSizeAllowed( File file, { ZipDecodeLimits limits = ZipDecodeLimits.standard, }) async { @@ -51,6 +52,18 @@ abstract final class SafeZipExtractor { '(${limits.maxCompressedBytes} bytes).', ); } + return length; + } + + /// Reads the whole file into memory after size check. + /// + /// Prefer [readAndDecodeFile] (file-stream decode) when you only need an + /// [Archive], so compressed bytes are not held as a separate [List]. + static Future> readBoundedBytes( + File file, { + ZipDecodeLimits limits = ZipDecodeLimits.standard, + }) async { + await ensureCompressedSizeAllowed(file, limits: limits); return file.readAsBytes(); } @@ -80,12 +93,33 @@ abstract final class SafeZipExtractor { return archive; } + /// Decodes [file] via [InputFileStream] (buffered file reads) instead of + /// materializing the full compressed payload as a [List] first. static Future readAndDecodeFile( File file, { ZipDecodeLimits limits = ZipDecodeLimits.standard, }) async { - final bytes = await readBoundedBytes(file, limits: limits); - return decodeBytes(bytes, limits: limits); + final compressedBytes = + await ensureCompressedSizeAllowed(file, limits: limits); + + final input = InputFileStream(file.path); + try { + final Archive archive; + try { + archive = ZipDecoder().decodeStream(input); + } on Object catch (error) { + throw SafeZipException('Failed to decode zip archive: $error'); + } + + _validateArchive( + archive, + compressedBytes: compressedBytes, + limits: limits, + ); + return archive; + } finally { + await input.close(); + } } static void _validateArchive( diff --git a/test/core/security/safe_zip_extractor_test.dart b/test/core/security/safe_zip_extractor_test.dart index 283a4f63..6d9fc28e 100644 --- a/test/core/security/safe_zip_extractor_test.dart +++ b/test/core/security/safe_zip_extractor_test.dart @@ -132,5 +132,18 @@ void main() { await SafeZipExtractor.readAndDecodeFile(zipFile, limits: _tightLimits); expect(decoded.first.name, 'ok.txt'); }); + + test('ensureCompressedSizeAllowed rejects oversize before decode', () async { + final file = File(p.join(tempDir.path, 'big.zip')); + await file.writeAsBytes(List.filled(5000, 1)); + + expect( + () => SafeZipExtractor.ensureCompressedSizeAllowed( + file, + limits: _tightLimits, + ), + throwsA(isA()), + ); + }); }); }