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
14 changes: 8 additions & 6 deletions lib/core/utils/local_file_backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,18 @@ class LocalFileBackend implements FileBackend {
File(_native(path)).openRead(offset);

@override
Future<void> write(String path, Stream<List<int>> data, {int? size}) async {
Future<void> write(
String path,
Stream<List<int>> data, {
int? size,
void Function(String staging)? onStaging,
}) async {
final native = _native(path);
// Beside the destination, not in a temp directory: a rename across
// filesystems is a copy, and this one has to be the cheap kind for the
// atomicity to be worth anything.
final staging = File('$native.${_stagingSuffix()}');
final staging = File(stagingNameFor(native));
onStaging?.call(staging.path);
try {
final sink = staging.openWrite();
try {
Expand Down Expand Up @@ -134,10 +140,6 @@ class LocalFileBackend implements FileBackend {
@override
Future<void> close() async {}

static var _staging = 0;

String _stagingSuffix() => '${kStagingSuffix.substring(1)}${_staging++}';

/// POSIX in, whatever this platform uses out.
///
/// The interface is POSIX-shaped so that a path can be handed from one
Expand Down
9 changes: 8 additions & 1 deletion lib/core/utils/monitor_file_backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,14 @@ class MonitorFileBackend implements FileBackend {
}

@override
Future<void> write(String path, Stream<List<int>> data, {int? size}) =>
Future<void> write(
String path,
Stream<List<int>> data, {
int? size,
// Never called: the staging happens inside the agent, under a name this
// side is not told and could not delete anyway.
void Function(String staging)? onStaging,
}) =>
// Atomic on the agent's side: it stages beside the destination and
// renames, which is the same contract the other two backends keep and
// the reason this one does not have to stage anything itself.
Expand Down
32 changes: 30 additions & 2 deletions lib/core/utils/proxy_command_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,34 @@ class ProxyCommandSocket implements SSHSocket {
static String debugExplain(String message, {required bool sandboxed}) =>
_explainFor(message, sandboxed: sandboxed);

/// Everything a hostname, an IPv4 or IPv6 literal, or a POSIX user name is
/// made of, and nothing a shell reads as syntax. `%` is absent on purpose:
/// a value carrying one could introduce a placeholder of its own.
static final _substitutable = RegExp(r'^[A-Za-z0-9._:@\-\[\]\\]*$');

/// Refuses a value that `/bin/sh` would not read as one word.
///
/// The expansion below is textual and the result is handed to `sh -c`, so a
/// host of `h; curl … | sh` is a local command that runs before anything has
/// been authenticated. That the ProxyCommand itself is the user's own is not
/// the answer: the address it expands is not necessarily — it arrives from
/// an imported `~/.ssh/config`, a restored backup or a synced peer.
///
/// Rejected rather than quoted. Quoting correctly means knowing which
/// context the placeholder sits in — bare, inside `"…"`, inside `'…'` — and
/// guessing that wrong is how a quoting fix becomes the next injection.
/// Nothing that names a real host or user is refused here.
@visibleForTesting
static String checkSubstitutable(String what, String value) {
if (_substitutable.hasMatch(value)) return value;
throw SSHErr(
type: SSHErrType.connect,
message:
'ProxyCommand cannot use this $what: "$value" contains characters '
'a shell would read as syntax.',
);
}

static String _resolveCommand({
required String command,
required String host,
Expand All @@ -202,9 +230,9 @@ class ProxyCommandSocket implements SSHSocket {
const percentPlaceholder = '\u0000PERCENT\u0000';
return command
.replaceAll('%%', percentPlaceholder)
.replaceAll('%h', host)
.replaceAll('%h', checkSubstitutable('host', host))
.replaceAll('%p', port.toString())
.replaceAll('%r', user)
.replaceAll('%r', checkSubstitutable('user', user))
.replaceAll(percentPlaceholder, '%');
}

Expand Down
Loading
Loading