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
2 changes: 2 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>` 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:
Expand Down
12 changes: 6 additions & 6 deletions lib/core/extensions/local_extension_installer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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).
///
Expand Down Expand Up @@ -44,15 +44,14 @@ class LocalExtensionInstaller {
}

onProgress?.call(0.1);
late final List<int> 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(
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<int>);
await outFile.writeAsBytes(file.content);
file.clear();
} else {
await Directory(targetPath).create(recursive: true);
}
Expand Down
18 changes: 10 additions & 8 deletions lib/core/market/http_marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';

Expand Down Expand Up @@ -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(
Expand All @@ -171,13 +171,13 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
);
}

late final List<int> 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}". '
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<int>);
final bytes = file.content;
await outFile.writeAsBytes(bytes);
file.clear();
} else {
await Directory(targetPath).create(recursive: true);
}
Expand Down
40 changes: 37 additions & 3 deletions lib/core/security/safe_zip_extractor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class SafeZipException implements Exception {

/// Bounded zip decode used by marketplace, sideload, and updater paths.
abstract final class SafeZipExtractor {
static Future<List<int>> readBoundedBytes(
/// Ensures [file] is within [limits.maxCompressedBytes] before reading.
static Future<int> ensureCompressedSizeAllowed(
File file, {
ZipDecodeLimits limits = ZipDecodeLimits.standard,
}) async {
Expand All @@ -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<List<int>> readBoundedBytes(
File file, {
ZipDecodeLimits limits = ZipDecodeLimits.standard,
}) async {
await ensureCompressedSizeAllowed(file, limits: limits);
return file.readAsBytes();
}

Expand Down Expand Up @@ -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<Archive> 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(
Expand Down
13 changes: 13 additions & 0 deletions test/core/security/safe_zip_extractor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>.filled(5000, 1));

expect(
() => SafeZipExtractor.ensureCompressedSizeAllowed(
file,
limits: _tightLimits,
),
throwsA(isA<SafeZipException>()),
);
});
});
}
Loading