From 506ec75e7de81c8163110f70860b6925499ed936 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 10:45:32 +0300 Subject: [PATCH] fix(security): detect Windows drive letters and reserved device names in isArchiveEntryNameSafe (Closes #919) --- lib/core/security/archive_path_guard.dart | 20 ++++++++- .../security/archive_path_guard_test.dart | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/core/security/archive_path_guard.dart b/lib/core/security/archive_path_guard.dart index 97006c6..c4b31d6 100644 --- a/lib/core/security/archive_path_guard.dart +++ b/lib/core/security/archive_path_guard.dart @@ -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; } diff --git a/test/core/security/archive_path_guard_test.dart b/test/core/security/archive_path_guard_test.dart index 01f64c7..c25bc30 100644 --- a/test/core/security/archive_path_guard_test.dart +++ b/test/core/security/archive_path_guard_test.dart @@ -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); }); }); }