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
11 changes: 10 additions & 1 deletion src/RdpManager/Core/AtomicFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@ public static void WriteAllText(string path, string contents)
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);

string tmp = path + ".tmp";
File.WriteAllText(tmp, contents, new UTF8Encoding(false));
// Flush(true) wymusza zrzut bajtów tmp na dysk PRZED atomowym rename — bez tego rename jest
// atomowy (stary plik nietknięty), ale zawartość tmp może jeszcze nie być trwała i utrata
// zasilania tuż po Move mogłaby zostawić pod `path` plik zerowy/uszkodzony.
using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
using (var sw = new StreamWriter(fs, new UTF8Encoding(false)))
{
sw.Write(contents);
sw.Flush();
fs.Flush(true);
}
File.Move(tmp, path, overwrite: true);
}

Expand Down
13 changes: 10 additions & 3 deletions src/RdpManager/Core/FtpsCertPinning.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,14 @@ public static Status Check(Dictionary<string, string> store, string host, int po
return string.Equals(known, fingerprint, StringComparison.OrdinalIgnoreCase) ? Status.Match : Status.Mismatch;
}

public static Dictionary<string, string> Load(string dir)
public static Dictionary<string, string> Load(string dir) => Load(dir, out _);

/// <summary>Jak <see cref="Load(string)"/>, ale ustawia <paramref name="storeUnreadable"/>=true, gdy plik
/// ISTNIEJE, lecz nie dało się go odczytać/sparsować (uszkodzony). Wołający MUSI wtedy działać
/// fail-closed (potraktować certyfikat jak ZMIANĘ), a nie wracać do TOFU „nowy serwer".</summary>
public static Dictionary<string, string> Load(string dir, out bool storeUnreadable)
{
storeUnreadable = false;
var path = Path.Combine(dir, "ftps_certs.json");
try
{
Expand All @@ -46,8 +52,9 @@ public static Dictionary<string, string> Load(string dir)
}
catch
{
// Uszkodzony plik: odłóż na bok (.corrupt) i zgłoś — zamiast po cichu skasować CAŁE zaufanie
// certyfikatów (każdy serwer wyglądałby jak nowy przy następnym połączeniu).
// Uszkodzony plik: odłóż na bok (.corrupt), zgłoś i zasygnalizuj wołającemu, żeby nie wracał
// po cichu do TOFU (zmieniony certyfikat po uszkodzeniu magazynu wyglądałby jak „nowy serwer").
storeUnreadable = true;
AtomicFile.PreserveCorrupt(path);
HealthNotices.Add(HealthNoticeKind.FileQuarantined, Path.GetFileName(path));
}
Expand Down
14 changes: 11 additions & 3 deletions src/RdpManager/Core/KnownHosts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ public static Status Check(Dictionary<string, string> store, string host, int po
return string.Equals(known, fingerprint, StringComparison.Ordinal) ? Status.Match : Status.Mismatch;
}

public static Dictionary<string, string> Load(string dir)
public static Dictionary<string, string> Load(string dir) => Load(dir, out _);

/// <summary>Jak <see cref="Load(string)"/>, ale ustawia <paramref name="storeUnreadable"/>=true, gdy plik
/// ISTNIEJE, lecz nie dało się go odczytać/sparsować (uszkodzony). Wołający MUSI wtedy działać
/// fail-closed (potraktować hosta jak ZMIANĘ klucza), a nie wracać do TOFU „nowy host" — inaczej po
/// uszkodzeniu magazynu zmieniony klucz (MITM) wyglądałby jak nowy i pytanie nie ostrzegłoby o ataku.</summary>
public static Dictionary<string, string> Load(string dir, out bool storeUnreadable)
{
storeUnreadable = false;
var path = Path.Combine(dir, "known_hosts.json");
try
{
Expand All @@ -45,8 +52,9 @@ public static Dictionary<string, string> Load(string dir)
}
catch
{
// Uszkodzony plik: odłóż na bok (.corrupt) i zgłoś — zamiast po cichu skasować CAŁE zaufanie
// hostów (dotąd wracaliśmy do pustki, więc przy następnym łączeniu każdy klucz wyglądał jak nowy).
// Uszkodzony plik: odłóż na bok (.corrupt), zgłoś i zasygnalizuj wołającemu, żeby nie wracał
// po cichu do TOFU (dotąd zmieniony klucz po uszkodzeniu magazynu wyglądał jak „nowy host").
storeUnreadable = true;
AtomicFile.PreserveCorrupt(path);
HealthNotices.Add(HealthNoticeKind.FileQuarantined, Path.GetFileName(path));
}
Expand Down
46 changes: 34 additions & 12 deletions src/RdpManager/Core/PasswordGen.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
Expand All @@ -19,29 +20,50 @@ public static class PasswordGen
// Znaki mylące wizualnie (O/0, I/l/1, itp.) — do opcjonalnego wykluczenia.
public const string Ambiguous = "O0oIl1|`'\"{}[]()/\\;:.,";

// Odfiltrowuje znaki mylące z jednej klasy (gdy włączone wykluczenie).
private static string Filter(string set, bool excludeAmbiguous)
=> excludeAmbiguous ? new string(set.Where(c => Ambiguous.IndexOf(c) < 0).ToArray()) : set;

/// <summary>Zbiór znaków dla wybranych klas (po ewentualnym wykluczeniu mylących).</summary>
public static string BuildPool(bool upper, bool lower, bool digits, bool symbols, bool excludeAmbiguous)
{
var sb = new StringBuilder();
if (upper) sb.Append(Upper);
if (lower) sb.Append(Lower);
if (digits) sb.Append(Digits);
if (symbols) sb.Append(Symbols);
var pool = sb.ToString();
if (excludeAmbiguous)
pool = new string(pool.Where(c => Ambiguous.IndexOf(c) < 0).ToArray());
return pool;
if (upper) sb.Append(Filter(Upper, excludeAmbiguous));
if (lower) sb.Append(Filter(Lower, excludeAmbiguous));
if (digits) sb.Append(Filter(Digits, excludeAmbiguous));
if (symbols) sb.Append(Filter(Symbols, excludeAmbiguous));
return sb.ToString();
}

public static string GeneratePassword(int length, bool upper, bool lower, bool digits, bool symbols,
bool excludeAmbiguous)
{
string pool = BuildPool(upper, lower, digits, symbols, excludeAmbiguous);
if (length <= 0 || pool.Length == 0) return "";
var sb = new StringBuilder(length);
for (int i = 0; i < length; i++)
sb.Append(pool[RandomNumberGenerator.GetInt32(pool.Length)]);
return sb.ToString();

// Gwarancja: po ≥1 znaku z KAŻDEJ wybranej klasy (o ile długość na to pozwala) — inaczej
// wygenerowane hasło mogło nie spełnić polityki złożoności serwera. Reszta z pełnej puli, na końcu tasujemy.
var classes = new List<string>();
if (upper) classes.Add(Filter(Upper, excludeAmbiguous));
if (lower) classes.Add(Filter(Lower, excludeAmbiguous));
if (digits) classes.Add(Filter(Digits, excludeAmbiguous));
if (symbols) classes.Add(Filter(Symbols, excludeAmbiguous));
classes.RemoveAll(s => s.Length == 0);

var chars = new char[length];
int p = 0;
foreach (var cls in classes)
if (p < length) chars[p++] = cls[RandomNumberGenerator.GetInt32(cls.Length)];
for (; p < length; p++)
chars[p] = pool[RandomNumberGenerator.GetInt32(pool.Length)];

// Fisher-Yates (krypto-RNG) — rozrzuć gwarantowane znaki po całej długości.
for (int i = length - 1; i > 0; i--)
{
int j = RandomNumberGenerator.GetInt32(i + 1);
(chars[i], chars[j]) = (chars[j], chars[i]);
}
return new string(chars);
}

/// <summary>Token hex (małe litery), <paramref name="byteCount"/> bajtów losowych → 2×hex znaków.</summary>
Expand Down
14 changes: 14 additions & 0 deletions src/RdpManager/Core/RdpUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ public static string MakeInitials(string name)
public static (string Host, int Port) SplitHostPort(string address, int defaultPort)
{
string host = (address ?? "").Trim();
// IPv6 w nawiasach: "[::1]" albo "[::1]:3389" — nawiasy usuwamy, port bierzemy tylko po "]".
if (host.StartsWith("[", StringComparison.Ordinal))
{
int close = host.IndexOf(']');
if (close > 1)
{
string inner = host.Substring(1, close - 1);
string rest = host.Substring(close + 1);
if (rest.StartsWith(":", StringComparison.Ordinal)
&& int.TryParse(rest.Substring(1), out var pp) && pp >= 1 && pp <= 65535)
return (inner, pp);
return (inner, defaultPort);
}
}
int i = host.LastIndexOf(':');
if (i > 0 && host.IndexOf(':') == i
&& int.TryParse(host.Substring(i + 1), out var p) && p >= 1 && p <= 65535)
Expand Down
49 changes: 36 additions & 13 deletions src/RdpManager/FileTransferPanel.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ public async Task<bool> TransferInLocalFileAsync(string localPath)
/// Pyta raz o nadpisanie, jeśli cel już istnieje lokalnie.</summary>
public async Task<bool> TransferOutToLocalAsync(string remoteFull, string remoteName, bool isDir, string localDir)
{
string dest = SafeCombine(localDir.Replace('/', '\\'), remoteName);
string dest;
try { dest = SafeCombine(localDir.Replace('/', '\\'), remoteName); }
catch (Exception ex) { SetStatus(ex.Message); return false; } // niebezpieczna nazwa ze zdalnego serwera → status, nie nieobsłużony wyjątek
if (System.IO.Directory.Exists(dest) || System.IO.File.Exists(dest))
{
if (AskOverwrite(remoteName) != OverwriteChoice.Overwrite) return false;
Expand All @@ -186,23 +188,36 @@ public async Task<bool> TransferOutToLocalAsync(string remoteFull, string remote

// ---------- Rekurencyjny transfer drzew (plik lub katalog); anulowanie + postęp bajtowy ----------

// Zabezpieczenie przed cyklem dowiązań (symlink/junction): pomijamy lokalne katalogi-reparse-pointy
// i ograniczamy głębokość rekurencji (także zdalnie, gdzie dowiązania trudniej wykryć). Bez tego pętla
// dowiązań kończyła się StackOverflowException, którego NIE łapie DispatcherUnhandledException (twardy crash).
private const int MaxTreeDepth = 100;

private static bool IsReparsePoint(string path)
{
try { return (System.IO.File.GetAttributes(path) & System.IO.FileAttributes.ReparsePoint) != 0; }
catch { return false; }
}

/// <summary>Wysyła lokalny plik/katalog do remoteDir na zdalnym fs (rekurencyjnie, bez śledzenia postępu).
/// Publiczne dla testów — TransferState jest prywatny, więc pełny wariant zostaje wewnętrzny.</summary>
public static void UploadTree(IRemoteFs fs, string localPath, string remoteDir, CancellationToken ct)
=> UploadTree(fs, localPath, remoteDir, ct, null);

// Wysyła lokalny plik/katalog do remoteDir na zdalnym fs; katalogi tworzy, w pliki wchodzi rekurencyjnie.
// Statyczna i bezstanowa poza jawnymi parametrami — testowalna bez UI (state/ct mogą być null/None).
private static void UploadTree(IRemoteFs fs, string localPath, string remoteDir, CancellationToken ct, TransferState state)
private static void UploadTree(IRemoteFs fs, string localPath, string remoteDir, CancellationToken ct, TransferState state, int depth = 0)
{
ct.ThrowIfCancellationRequested();
if (depth > MaxTreeDepth) throw new System.IO.IOException(string.Format(L("S.sftp.toodeep"), MaxTreeDepth));
string name = System.IO.Path.GetFileName(localPath.TrimEnd('/', '\\'));
string target = remoteDir.TrimEnd('/') + "/" + name;
if (System.IO.Directory.Exists(localPath))
{
if (depth > 0 && IsReparsePoint(localPath)) return; // nie wchodź w dowiązanie — ryzyko cyklu
EnsureRemoteDir(fs, target);
foreach (var sub in System.IO.Directory.GetDirectories(localPath)) UploadTree(fs, sub, target, ct, state);
foreach (var f in System.IO.Directory.GetFiles(localPath)) UploadTree(fs, f, target, ct, state);
foreach (var sub in System.IO.Directory.GetDirectories(localPath)) UploadTree(fs, sub, target, ct, state, depth + 1);
foreach (var f in System.IO.Directory.GetFiles(localPath)) UploadTree(fs, f, target, ct, state, depth + 1);
}
else
{
Expand All @@ -222,14 +237,15 @@ public static void DownloadTree(IRemoteFs fs, string remoteFull, string remoteNa
// remoteName pochodzi z listingu ZDALNEGO serwera — złośliwy/skompromitowany serwer mógłby zwrócić
// "..\..\", ścieżkę z literą dysku itp., próbując zapisać poza wybranym katalogiem ("zip-slip" /
// path traversal). SafeCombine odrzuca takie nazwy i wymusza pozostanie wewnątrz localParentDir.
private static void DownloadTree(IRemoteFs fs, string remoteFull, string remoteName, bool isDir, string localParentDir, CancellationToken ct, TransferState state)
private static void DownloadTree(IRemoteFs fs, string remoteFull, string remoteName, bool isDir, string localParentDir, CancellationToken ct, TransferState state, int depth = 0)
{
ct.ThrowIfCancellationRequested();
if (depth > MaxTreeDepth) throw new System.IO.IOException(string.Format(L("S.sftp.toodeep"), MaxTreeDepth));
string dest = SafeCombine(localParentDir, remoteName);
if (isDir)
{
System.IO.Directory.CreateDirectory(dest);
foreach (var e in fs.List(remoteFull)) DownloadTree(fs, e.FullName, e.Name, e.IsDir, dest, ct, state);
foreach (var e in fs.List(remoteFull)) DownloadTree(fs, e.FullName, e.Name, e.IsDir, dest, ct, state, depth + 1);
}
else
{
Expand All @@ -248,12 +264,13 @@ private static void AddBytes(TransferState state, int delta)
}

// Sumuje rozmiar CAŁEGO lokalnego poddrzewa (do paska postępu przy wysyłce) — tanie, bez sieci.
private static long LocalTreeSize(string path)
private static long LocalTreeSize(string path, int depth = 0)
{
if (System.IO.Directory.Exists(path))
{
if (depth > MaxTreeDepth || (depth > 0 && IsReparsePoint(path))) return 0;
long sum = 0;
foreach (var d in System.IO.Directory.GetDirectories(path)) sum += LocalTreeSize(d);
foreach (var d in System.IO.Directory.GetDirectories(path)) sum += LocalTreeSize(d, depth + 1);
foreach (var f in System.IO.Directory.GetFiles(path)) { try { sum += new System.IO.FileInfo(f).Length; } catch { } }
return sum;
}
Expand All @@ -262,11 +279,12 @@ private static long LocalTreeSize(string path)

// Sumuje rozmiar CAŁEGO zdalnego poddrzewa (do paska postępu przy pobieraniu) — dodatkowe listowania,
// ale ograniczone dokładnie do drzewa, które i tak zaraz pobierzemy.
private static long RemoteTreeSize(IRemoteFs fs, string remoteFull, bool isDir, long knownLength)
private static long RemoteTreeSize(IRemoteFs fs, string remoteFull, bool isDir, long knownLength, int depth = 0)
{
if (!isDir) return knownLength;
if (depth > MaxTreeDepth) return 0;
long sum = 0;
foreach (var e in fs.List(remoteFull)) sum += RemoteTreeSize(fs, e.FullName, e.IsDir, e.Length);
foreach (var e in fs.List(remoteFull)) sum += RemoteTreeSize(fs, e.FullName, e.IsDir, e.Length, depth + 1);
return sum;
}

Expand Down Expand Up @@ -350,12 +368,15 @@ await Task.Run(() =>
private async Task<bool> RunTransferAsync(Func<IRemoteFs, long> computeTotal, Func<string, string> statusFormat,
Action<IRemoteFs, CancellationToken, TransferState> transfer)
{
// Już trwa operacja/transfer → zignoruj nowe żądanie (nie przerywaj bieżącego). Wcześniej ta metoda
// anulowała trwający transfer, a RunAsync i tak zwracał false przez _busy — czyli niszczyła bieżący
// transfer i porzucała nowy. Przyciski i tak są odblokowane, więc dubel-klik trafiał w tę ścieżkę.
if (_busy) return false;

var state = new TransferState();
state.OnFileStarted = name => Dispatcher.BeginInvoke(new Action(() => SetStatus(statusFormat(name))));
state.OnBytesChanged = () => ReportBytesThrottled(state);

_transferCts?.Cancel();
_transferCts?.Dispose();
var cts = new CancellationTokenSource();
_transferCts = cts;
_xferState = state;
Expand Down Expand Up @@ -600,7 +621,9 @@ private async void Download_Click(object sender, RoutedEventArgs e)
var fdlg = new Microsoft.Win32.OpenFolderDialog();
if (fdlg.ShowDialog() != true) return;

string dest = SafeCombine(fdlg.FolderName, r.Name);
string dest;
try { dest = SafeCombine(fdlg.FolderName, r.Name); }
catch (Exception ex) { SetStatus(ex.Message); return; }
if (System.IO.Directory.Exists(dest) || System.IO.File.Exists(dest))
{
var choice = AskOverwrite(r.Name);
Expand Down
5 changes: 3 additions & 2 deletions src/RdpManager/FtpFs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ private void OnValidateCertificate(BaseFtpClient control, FtpSslValidationEventA
bool changed;
lock (Core.FtpsCertPinning.Sync)
{
var store = Core.FtpsCertPinning.Load(SettingsStore.Dir);
var store = Core.FtpsCertPinning.Load(SettingsStore.Dir, out bool unreadable);
var status = Core.FtpsCertPinning.Check(store, _server.Host, port, fp);
if (status == Core.FtpsCertPinning.Status.Match) { e.Accept = true; return; }
changed = status == Core.FtpsCertPinning.Status.Mismatch;
// Uszkodzony magazyn → fail-closed: traktuj jak ZMIANĘ certyfikatu (ostrzeżenie, domyślnie odrzuć).
changed = status == Core.FtpsCertPinning.Status.Mismatch || unreadable;
}

var ask = TrustCertificate;
Expand Down
Loading
Loading