Fix/download path validation (#16601)

* fix: validate download destinations and peer filename rules

Reject unsafe or linked download destinations and validate empty-directory
paths before creating them. Bind overwrite approval to accepted transfer
entries and propagate receiver errors to the UI.

Validate remote listings using the peer's filename rules so Unix names
remain browsable on Windows. Reject locally unsafe basenames before
starting downloads from either the Flutter or legacy UI.

Finish the previous file before validating the next digest so rejecting
the next destination preserves the file already received.

Require confirmation before restarting interrupted downloads with stale
staging files. Preserve matching resumes and cover receiver recovery.

Retain explicit overwrite rejection for peers older than 1.1.10 that
cannot negotiate confirmation.

Signed-off-by: fufesou <linlong1266@gmail.com>

* comments

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: use async I/O for download preparation and resume

Use Tokio filesystem APIs for download setup and resume metadata checks. Run the Windows link-count query on the blocking pool and flush digest writes before returning.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou authored and GitHub committed 2026-10-21 11:55:00 +08:00
1 parent eef1096395
commit b44b71cfab
7 files changed
+1014 -14

No files matched your search

+55
View File
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/utils/download_paths.dart';
import 'package:flutter_hbb/utils/event_loop.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
@@ -627,6 +628,17 @@ class FileController {
final transferJobIds = <int>[];
for (var from in items.items) {
final jobID = jobController.addTransferJob(from, isRemoteToLocal);
// Keep this check before PathUtil.join: Rust does not receive the selected
// root separately, and Web transfers bypass Rust validation.
if (isRemoteToLocal &&
!DownloadPaths.validName(from.name, windows: isWindows)) {
jobController.jobError({
'id': '$jobID',
'file_num': '0',
'err': 'Invalid file name: ${from.name}',
});
continue;
}
transferJobs.add((from, jobID));
transferJobIds.add(jobID);
}
@@ -651,6 +663,15 @@ class FileController {
return;
}
if (isRemoteToLocal) {
for (final (entry, jobID) in transferJobs) {
if (entry.isDirectory) {
await _downloadEmptyDirectories(entry, jobID, otherSideData);
}
}
return;
}
final List<Entry> entrys = items.items.toList();
var isRemote = isLocal == true ? true : false;
@@ -683,6 +704,35 @@ class FileController {
});
}
Future<void> _downloadEmptyDirectories(
Entry entry, int jobID, DirectoryData destination) async {
try {
final emptyDirs = await fileFetcher.readEmptyDirs(
entry.path, false, destination.options.showHidden);
final localRoot = PathUtil.join(destination.directory.path, entry.name,
destination.options.isWindows);
final paths = emptyDirs
.map((dir) => DownloadPaths.mapDirectory(
entry.path, dir.path, localRoot,
remoteWindows: options.value.isWindows,
localWindows: destination.options.isWindows))
.toList();
for (final path in paths) {
await DownloadPaths.ensureNoLinks(destination.directory.path, path);
}
for (final path in paths) {
await createDirWithRemote(path, false);
}
} on FormatException catch (err) {
await bind.sessionCancelJob(sessionId: sessionId, actId: jobID);
jobController.jobError({
'id': '$jobID',
'file_num': '0',
'err': err.message,
});
}
}
bool _removeCheckboxRemember = false;
Future<void> removeAction(SelectedItems items) async {
@@ -1120,6 +1170,11 @@ class JobController {
return true;
}
final job = jobTable[jobIndex];
if (job.type == JobType.transfer &&
job.isRemoteToLocal &&
job.state == JobState.error) {
return true;
}
job.recvJobRes = true;
if (job.type == JobType.deleteFile) {
job.state = JobState.done;
+52
View File
@@ -0,0 +1,52 @@
import 'dart:io';
import 'package:path/path.dart' as path;
class DownloadPaths {
static Future<void> ensureNoLinks(String root, String destination) async {
if (!path.isWithin(root, destination)) {
throw FormatException('Invalid download directory: $destination');
}
var current = root;
for (final name in path.split(path.relative(destination, from: root))) {
current = path.join(current, name);
if (await FileSystemEntity.type(current, followLinks: false) ==
FileSystemEntityType.link) {
throw FormatException('Download directory contains a link: $current');
}
}
}
static bool validName(String name, {required bool windows}) {
if (name.isEmpty || name == '.' || name == '..' || name.contains('\x00')) {
return false;
}
if (name.contains('/')) return false;
return !windows ||
(!name.contains(RegExp(r'[<>:"\\|?*]')) &&
!name.endsWith('.') &&
!name.endsWith(' '));
}
static String mapDirectory(
String remoteRoot, String remotePath, String localRoot,
{required bool remoteWindows, required bool localWindows}) {
final remote = path.Context(
style: remoteWindows ? path.Style.windows : path.Style.posix);
final local = path.Context(
style: localWindows ? path.Style.windows : path.Style.posix);
if (remotePath.contains('\x00') ||
remote.split(remotePath).contains('..') ||
(!remote.equals(remoteRoot, remotePath) &&
!remote.isWithin(remoteRoot, remotePath))) {
throw FormatException('Invalid download directory: $remotePath');
}
final relative = remote.relative(remotePath, from: remoteRoot);
if (relative == '.') return localRoot;
final names = remote.split(relative);
if (names.any((name) => !validName(name, windows: localWindows))) {
throw FormatException('Invalid download directory: $remotePath');
}
return local.joinAll([localRoot, ...names]);
}
}
+174
View File
@@ -0,0 +1,174 @@
import 'dart:io';
import 'package:flutter_hbb/generated_bridge.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:uuid/uuid.dart';
final _sessionId = UuidValue('00000000-0000-4000-8000-000000000001');
class _Binding extends Fake implements RustdeskImpl {
final sent = <String>[];
final created = <String>[];
int nextId = 0;
@override
String mainGetCommonSync({required String key, dynamic hint}) =>
'${++nextId}';
@override
int versionToNumber({required String v, dynamic hint}) =>
v == '1.3.3' ? 1 : 2;
@override
Future<void> sessionSendFiles({
required UuidValue sessionId,
required int actId,
required String path,
required String to,
required int fileNum,
required bool includeHidden,
required bool isRemote,
required bool isDir,
dynamic hint,
}) async {
expect(isRemote, isTrue);
sent.add(to);
}
@override
Future<void> sessionCreateDir({
required UuidValue sessionId,
required int actId,
required String path,
required bool isRemote,
dynamic hint,
}) async {
expect(isRemote, isFalse);
created.add(path);
}
@override
Future<void> sessionCancelJob({
required UuidValue sessionId,
required int actId,
dynamic hint,
}) async {}
}
class _Session extends Fake implements FFI {
@override
final String id = 'download-test';
@override
UuidValue get sessionId => _sessionId;
@override
late final ffiModel = FfiModel(WeakReference(this));
}
class _Fetcher extends FileFetcher {
_Fetcher() : super(() => _sessionId);
List<String> emptyDirectories = [];
@override
Future<List<FileDirectory>> readEmptyDirs(
String path, bool isLocal, bool showHidden) async =>
emptyDirectories.map((path) => FileDirectory()..path = path).toList();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _Binding binding;
late _Session session;
late _Fetcher fetcher;
late FileController controller;
late DirectoryData destination;
setUp(() {
binding = _Binding();
platformFFI.setBindingForTesting(binding);
session = _Session();
session.ffiModel.pi.version = '1.5.1';
fetcher = _Fetcher();
destination =
DirectoryData(FileDirectory()..path = '/downloads', DirectoryOptions());
controller = FileController(
isLocal: false,
getSessionID: () => _sessionId,
rootState: WeakReference(session),
jobController: JobController(() => _sessionId, () => null),
fileFetcher: fetcher,
getOtherSideDirectoryData: () => destination,
);
controller.directory.value.path = '/remote';
});
tearDown(() => session.ffiModel.dispose());
Future<void> download(String name, {bool directory = false}) async {
final entry = Entry()
..name = name
..path = '/remote/$name'
..entryType = directory ? 0 : 4;
final items = SelectedItems(isLocal: false)..add(entry);
await controller.sendFiles(items, destination);
}
test('downloads refuse peer listing names that escape the local folder',
() async {
await download('report.txt');
expect(binding.sent, ['/downloads/report.txt']);
binding.sent.clear();
for (final windows in [false, true]) {
destination.options.isWindows = windows;
destination.directory.path = windows ? r'C:\downloads' : '/downloads';
for (final name in [
'../outside.txt',
'/outside.txt',
'.',
'..',
'',
'bad\x00name',
if (windows) ...[r'..\outside.txt', r'C:\outside.txt', r'\\host\share'],
]) {
await download(name);
expect(binding.sent, isEmpty, reason: name);
expect(controller.jobController.jobTable.last.state, JobState.error);
}
}
await controller.jobController.jobDone({
'id': '${controller.jobController.jobTable.last.id}',
'file_num': '0',
});
expect(controller.jobController.jobTable.last.state, JobState.error);
});
test('empty-directory downloads reject paths outside the selected folder',
() async {
for (final path in ['/remote/other/empty', '/remote/docs/../out']) {
fetcher.emptyDirectories = ['/remote/docs/ok', path];
await download('docs', directory: true);
expect(binding.created, isEmpty, reason: path);
expect(controller.jobController.jobTable.last.state, JobState.error);
}
fetcher.emptyDirectories = ['/remote/docs', '/remote/docs/sub/empty'];
await download('docs', directory: true);
expect(binding.created, ['/downloads/docs', '/downloads/docs/sub/empty']);
});
test('empty-directory downloads refuse existing local links', () async {
final temporary =
await Directory.systemTemp.createTemp('rustdesk_download_');
addTearDown(() => temporary.delete(recursive: true));
final outside = await Directory('${temporary.path}/outside').create();
final downloads = await Directory('${temporary.path}/downloads').create();
await Link('${downloads.path}/docs').create(outside.path);
destination.directory.path = downloads.path;
destination.options.isWindows = Platform.isWindows;
fetcher.emptyDirectories = ['/remote/docs/empty'];
await download('docs', directory: true);
expect(binding.created, isEmpty);
expect(controller.jobController.jobTable.last.state, JobState.error);
});
}