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
20 changes: 19 additions & 1 deletion lib/core/security/archive_path_guard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,27 @@ bool isArchiveExtractPathWithinRoot(String rootPath, String targetPath) {
return p.equals(root, target) || p.isWithin(root, target);
}

/// Rejects archive entry names that attempt absolute paths or traversal.
final _controlCharacters = RegExp(r'[\x00-\x1F\x7F]');
final _windowsDriveLetter = RegExp(r'(?:^|[/\\])[a-zA-Z]:');
final _windowsReservedDevice = RegExp(
r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$',
caseSensitive: false,
);

/// Rejects archive entry names that attempt absolute paths, directory traversal,
/// Windows drive letters, Windows reserved device names, or control characters.
bool isArchiveEntryNameSafe(String entryName) {
if (entryName.isEmpty) return false;
if (_controlCharacters.hasMatch(entryName)) return false;
if (entryName.contains('..')) return false;
if (entryName.startsWith('/') || entryName.startsWith('\\')) return false;
if (_windowsDriveLetter.hasMatch(entryName)) return false;

final segments = entryName.split(RegExp(r'[/\\]'));
for (final segment in segments) {
final trimmed = segment.trimRight();
if (_windowsReservedDevice.hasMatch(trimmed)) return false;
}

return true;
}
45 changes: 45 additions & 0 deletions test/core/security/archive_path_guard_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,51 @@ void main() {
test('allows relative safe names', () {
expect(isArchiveEntryNameSafe('manifest.json'), isTrue);
expect(isArchiveEntryNameSafe('bin/driver'), isTrue);
expect(isArchiveEntryNameSafe('icons/icon.png'), isTrue);
expect(isArchiveEntryNameSafe('src/control.dart'), isTrue);
expect(isArchiveEntryNameSafe('lib/auxiliary.py'), isTrue);
expect(isArchiveEntryNameSafe('data/printer.dart'), isTrue);
expect(isArchiveEntryNameSafe('com10.txt'), isTrue);
expect(isArchiveEntryNameSafe('null_safety.dart'), isTrue);
});

test('rejects empty entry names', () {
expect(isArchiveEntryNameSafe(''), isFalse);
});

test('rejects Windows drive letters (#919)', () {
expect(isArchiveEntryNameSafe(r'C:\Windows\System32\payload.dll'), isFalse);
expect(isArchiveEntryNameSafe('C:/test.txt'), isFalse);
expect(isArchiveEntryNameSafe(r'c:\test.txt'), isFalse);
expect(isArchiveEntryNameSafe('D:file.exe'), isFalse);
expect(isArchiveEntryNameSafe(r'sub/C:\test.txt'), isFalse);
expect(isArchiveEntryNameSafe(r'sub\D:file.exe'), isFalse);
});

test('rejects Windows reserved device names and their extensions (#919)', () {
final reserved = [
'CON', 'con', 'CON.txt', 'con.json',
'PRN', 'prn', 'PRN.dat', 'prn.txt',
'AUX', 'aux', 'aux.h', 'AUX.tar.gz',
'NUL', 'nul', 'nul.png', 'NUL.txt',
'COM1', 'com1.txt', 'COM9', 'com9.exe',
'LPT1', 'lpt1.txt', 'LPT9', 'lpt9.dat',
];
for (final name in reserved) {
expect(isArchiveEntryNameSafe(name), isFalse, reason: 'Expected $name to be rejected');
expect(isArchiveEntryNameSafe('nested/$name'), isFalse, reason: 'Expected nested/$name to be rejected');
expect(isArchiveEntryNameSafe('sub\\path\\$name'), isFalse, reason: 'Expected sub\\path\\$name to be rejected');
expect(isArchiveEntryNameSafe('$name/file.txt'), isFalse, reason: 'Expected $name/file.txt to be rejected');
}
});

test('rejects null bytes and control characters (#919)', () {
expect(isArchiveEntryNameSafe('file\x00name.txt'), isFalse);
expect(isArchiveEntryNameSafe('file\nname.txt'), isFalse);
expect(isArchiveEntryNameSafe('file\rname.txt'), isFalse);
expect(isArchiveEntryNameSafe('file\x1Bname.txt'), isFalse);
expect(isArchiveEntryNameSafe('file\x7Fname.txt'), isFalse);
expect(isArchiveEntryNameSafe('dir/\x00/evil.txt'), isFalse);
});
});
}
Loading