Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried

- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive
  letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided
  where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the
  resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot
  be a file there is left on the host and reported.
- EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run
  it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on
  macOS, and refuses a .desktop launcher on Linux.
- A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so
  the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue
  starts those over.
- The SFTP host picker re-found its selection from the empty moment of every vault reload and landed
  on the first host once a minute; the chosen host is now remembered, and a typed password is only
  sent to the host it was typed for.
This commit is contained in:
jaap-jan committed 2026-10-02 13:16:30 +02:00
1 parent 4c7f47c026
commit 03c8e8589a
12 files changed
+586 -83

No files matched your search

@@ -0,0 +1,70 @@
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// <c>LocalEditor</c>: a remote file opened for editing is never run.
/// </summary>
/// <remarks>
/// The file's name was chosen by whoever can write to the host, so the question for every name here is
/// whether the desktop's default action would execute it. Only the Windows rule is exercised directly — it
/// is a pure function of the name and <c>PATHEXT</c> — while what each platform starts is checked on the
/// platform the suite is running on.
/// </remarks>
public sealed class LocalEditorTests
{
[Theory]
[InlineData("deploy.bat")]
[InlineData("DEPLOY.CMD")]
[InlineData("notes.hta")]
[InlineData("setup.js")]
[InlineData("link.lnk")]
[InlineData("run.ps1")]
[InlineData("settings.reg")]
[InlineData("deploy.bat.")]
[InlineData("deploy.bat ")]
public void AFileWindowsWouldRun_IsRecognised(string name) =>
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeTrue();
[Theory]
[InlineData("nginx.conf")]
[InlineData("authorized_keys")]
[InlineData(".bashrc")]
[InlineData("app.json")]
[InlineData("bat")]
public void AnOrdinaryConfigFile_IsLeftToItsAssociation(string name) =>
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeFalse();
[Fact]
public void WhateverPathExtAdds_IsTreatedAsRunnableToo() =>
LocalEditor.WouldRunOnWindows("tool.foo", pathExt: ".COM;.EXE;.FOO").ShouldBeTrue();
[Fact]
public void ARunnableFile_IsNeverHandedToTheDefaultOpenAction()
{
var path = Path.Combine(Path.GetTempPath(), "deploy.bat");
if (OperatingSystem.IsLinux())
{
// No portable editor to name, and a .bat is not something a Linux desktop runs.
LocalEditor.StartInfoFor(path).UseShellExecute.ShouldBeTrue();
Should.Throw<InvalidOperationException>(() => LocalEditor.StartInfoFor("/tmp/app.desktop"));
return;
}
var start = LocalEditor.StartInfoFor(OperatingSystem.IsMacOS() ? "/tmp/run.command" : path);
// Either the type's own "edit" verb, or a named editor with the file as an argument — never the
// file itself under its default verb.
(start.Verb == "edit" || !start.UseShellExecute).ShouldBeTrue();
}
[Fact]
public void AnOrdinaryFile_StillOpensWithItsAssociation()
{
var start = LocalEditor.StartInfoFor(Path.Combine(Path.GetTempPath(), "nginx.conf"));
start.UseShellExecute.ShouldBeTrue();
start.Verb.ShouldBeEmpty();
}
}
@@ -131,6 +131,35 @@ public sealed class TransferQueueingTests : IDisposable
/// SFTP fake that a real connect produces.
/// </para>
/// </remarks>
/// <summary>
/// A name the host chose cannot choose where on this machine the download lands.
/// </summary>
/// <remarks>
/// Backslashes and colons are ordinary in a POSIX file name and are separators and drive letters on
/// Windows, and a slash arrives in a name from an S3 key — so every one of these would have escaped the
/// folder through <c>Path.Combine</c> somewhere. They are queued inside it under a cleaned name, and the
/// bare <c>..</c>, which no cleaning can make a file name, is left on the host and said so.
/// </remarks>
[Fact]
public void ADownloadNamedToEscapeTheFolder_LandsInsideItOrNotAtAll()
{
Connected();
transfers.QueueDownloads(
[
RemoteFile(@"..\..\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\x.bat"),
RemoteFile(@"C:\Windows\evil.dll"),
RemoteFile("../../escaped.txt"),
RemoteFile(".."),
]);
var landed = Queued().Select(row => row.Transfer.LocalPath).ToList();
landed.Count.ShouldBe(3);
landed.ShouldAllBe(path => Path.GetDirectoryName(path) == directory);
transfers.Status.ShouldContain("1 name points outside the folder");
}
[Fact]
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
{