Skip to content

Commit 135404d

Browse files
Merge pull request #404 from QueryaHub/issue/401-archive-is-within
fix(security): use p.isWithin for archive extraction bounds
2 parents e87145a + 6a9c89e commit 135404d

5 files changed

Lines changed: 74 additions & 8 deletions

File tree

‎lib/core/extensions/local_extension_installer.dart‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
1010
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
1111
import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart';
1212
import 'package:querya_desktop/core/market/marketplace_repository.dart';
13+
import 'package:querya_desktop/core/security/archive_path_guard.dart';
1314

1415
/// Installs an extension package from a local `.zip` / `.qext` archive (issue #316).
1516
///
@@ -216,17 +217,15 @@ class LocalExtensionInstaller {
216217
}
217218
if (filename.isEmpty || filename == '/') continue;
218219

219-
if (filename.contains('..') ||
220-
filename.startsWith('/') ||
221-
filename.startsWith('\\')) {
220+
if (!isArchiveEntryNameSafe(filename)) {
222221
throw MarketplaceException(
223222
'Security violation: Path traversal detected in archive entry '
224223
'"${file.name}"',
225224
);
226225
}
227226

228227
final targetPath = p.normalize(p.join(destPath, filename));
229-
if (!targetPath.startsWith(destPath)) {
228+
if (!isArchiveExtractPathWithinRoot(destPath, targetPath)) {
230229
throw MarketplaceException(
231230
'Security violation: Extraction path out of bounds "${file.name}"',
232231
);

‎lib/core/market/http_marketplace_repository.dart‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart';
1111
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
1212
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
1313
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
14+
import 'package:querya_desktop/core/security/archive_path_guard.dart';
1415
import 'marketplace_repository.dart';
1516

1617
/// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend.
@@ -152,12 +153,12 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
152153
for (final file in archive) {
153154
final filename = file.name;
154155
// Check for Path Traversal attempts
155-
if (filename.contains('..') || filename.startsWith('/') || filename.startsWith('\\')) {
156+
if (!isArchiveEntryNameSafe(filename)) {
156157
throw MarketplaceException('Security violation: Path traversal detected in archive entry "$filename"');
157158
}
158159

159160
final targetPath = p.normalize(p.join(extDirPath, filename));
160-
if (!targetPath.startsWith(extDirPath)) {
161+
if (!isArchiveExtractPathWithinRoot(extDirPath, targetPath)) {
161162
throw MarketplaceException('Security violation: Extraction path out of bounds "$filename"');
162163
}
163164

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'package:path/path.dart' as p;
2+
3+
/// Returns true when [targetPath] equals [rootPath] or lies inside it.
4+
///
5+
/// Prefer over [String.startsWith] so sibling prefixes (e.g. `/tmp/abc` vs
6+
/// `/tmp/abcd`) cannot bypass extraction bounds.
7+
bool isArchiveExtractPathWithinRoot(String rootPath, String targetPath) {
8+
final root = p.normalize(rootPath);
9+
final target = p.normalize(targetPath);
10+
return p.equals(root, target) || p.isWithin(root, target);
11+
}
12+
13+
/// Rejects archive entry names that attempt absolute paths or traversal.
14+
bool isArchiveEntryNameSafe(String entryName) {
15+
if (entryName.contains('..')) return false;
16+
if (entryName.startsWith('/') || entryName.startsWith('\\')) return false;
17+
return true;
18+
}

‎lib/core/updater/installers/update_install_utils.dart‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:io';
33
import 'package:archive/archive.dart';
44
import 'package:path/path.dart' as p;
55

6+
import '../../security/archive_path_guard.dart';
67
import '../app_updater_service.dart';
78

89
/// Safely extracts a zip archive into [destinationDir].
@@ -21,14 +22,14 @@ Future<void> extractZipSecurely({
2122

2223
for (final entry in archive) {
2324
final name = entry.name;
24-
if (name.contains('..') || name.startsWith('/') || name.startsWith('\\')) {
25+
if (!isArchiveEntryNameSafe(name)) {
2526
throw AppUpdaterException(
2627
'Security violation: path traversal in archive entry "$name"',
2728
);
2829
}
2930

3031
final targetPath = p.normalize(p.join(root, name));
31-
if (!targetPath.startsWith(root)) {
32+
if (!isArchiveExtractPathWithinRoot(root, targetPath)) {
3233
throw AppUpdaterException(
3334
'Security violation: extraction path out of bounds "$name"',
3435
);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:querya_desktop/core/security/archive_path_guard.dart';
3+
4+
void main() {
5+
group('isArchiveExtractPathWithinRoot', () {
6+
test('allows paths inside root', () {
7+
expect(
8+
isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/ext/file.txt'),
9+
isTrue,
10+
);
11+
});
12+
13+
test('allows root path itself', () {
14+
expect(
15+
isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/ext'),
16+
isTrue,
17+
);
18+
});
19+
20+
test('rejects sibling prefix paths (startsWith false positive)', () {
21+
expect(
22+
isArchiveExtractPathWithinRoot('/tmp/abc', '/tmp/abcd/evil.txt'),
23+
isFalse,
24+
);
25+
});
26+
27+
test('rejects paths outside root', () {
28+
expect(
29+
isArchiveExtractPathWithinRoot('/tmp/ext', '/tmp/other/file.txt'),
30+
isFalse,
31+
);
32+
});
33+
});
34+
35+
group('isArchiveEntryNameSafe', () {
36+
test('rejects traversal and absolute names', () {
37+
expect(isArchiveEntryNameSafe('../evil.txt'), isFalse);
38+
expect(isArchiveEntryNameSafe('/etc/passwd'), isFalse);
39+
expect(isArchiveEntryNameSafe(r'\windows\system32'), isFalse);
40+
});
41+
42+
test('allows relative safe names', () {
43+
expect(isArchiveEntryNameSafe('manifest.json'), isTrue);
44+
expect(isArchiveEntryNameSafe('bin/driver'), isTrue);
45+
});
46+
});
47+
}

0 commit comments

Comments
 (0)