From ad4013df8d6a563d651645d587f5df0efed6ca4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:10:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(app):=20przegl=C4=85d=20=E2=80=94=20crash?= =?UTF-8?q?=20transferu,=20utwardzenie=20aktualizacji/known-hosts=20i=20po?= =?UTF-8?q?prawki=20bug=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High - FileTransferPanel: rekurencyjny transfer (upload/download/rozmiar) pomija dowiązania (symlink/junction) i ma limit głębokości — koniec z pętlą → StackOverflow (twardy crash, którego nie łapie DispatcherUnhandledException). Nowy klucz i18n S.sftp.toodeep (EN+PL). Medium - UpdateService: adres pobrania aktualizacji musi być https + host GitHuba (IsTrustedDownloadUrl); przy niepodpisanym buildzie to jedyna twarda kontrola pochodzenia pobieranego exe. - KnownHosts / FtpsCertPinning: Load(dir, out storeUnreadable) — uszkodzony magazyn ⇒ fail-closed (host/cert traktowany jak ZMIANA klucza, ostrzeżenie) zamiast cichego „nowy host?". - FileTransferPanel: drugi transfer nie niszczy trwającego i nie jest porzucany (guard _busy). - XtermControl: nieudany init WebView2 zeruje _ready — ponowna próba re-inicjalizuje zamiast wisieć. - FileTransferPanel: SafeCombine na nazwach ze zdalnego serwera złapane w handlerach (status „niebezpieczna nazwa" zamiast nieobsłużonego wyjątku / cichego no-op w Release). - RestClient: ConfigureAwait(false) na wszystkich awaitach (dekodowanie dużych odpowiedzi poza UI). - RestConsole: kolory JSON z palety (per-motyw) — czytelne w jasnym motywie. Low - RestClient: dekodowanie ciała wg charset z Content-Type (fallback UTF-8). - AtomicFile: Flush(true) przed atomowym rename (trwałość zapisu). - RdpUtils.SplitHostPort: obsługa IPv6 w nawiasach ([::1]:3389). - PasswordGen: gwarancja po ≥1 znaku z każdej wybranej klasy (Fisher-Yates). - RestStore: self-heal z .bak (wzorem EnvironmentStore) — brak utraty kolekcji przy uszkodzeniu. - ReachabilityService: token anuluje ConnectAsync (bez porzuconego, nieobserwowanego zadania). - RestConsole/ServerEditWindow: font Mono zamiast Consolas; IsDefault na „Zapisz". Świadomie odłożone (wyższe ryzyko bez lokalnego builda / decyzje projektowe): reuse HttpClient, RestScript poza wątkiem UI, mikro-wyciek TOCTOU przy zamknięciu w trakcie łączenia, strip nagłówków przy cross-origin redirect, AutomationProperties dla rysowanych zakładek/drzewa, odświeżanie napisów w otwartych oknach przy zmianie języka, sub-12px czcionki i promienie poza skalą Metrics. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KXXgwUeSkZsXYVKRzyMvV9 --- src/RdpManager/Core/AtomicFile.cs | 11 ++++- src/RdpManager/Core/FtpsCertPinning.cs | 13 +++-- src/RdpManager/Core/KnownHosts.cs | 14 ++++-- src/RdpManager/Core/PasswordGen.cs | 46 ++++++++++++----- src/RdpManager/Core/RdpUtils.cs | 14 ++++++ src/RdpManager/FileTransferPanel.xaml.cs | 49 ++++++++++++++----- src/RdpManager/FtpFs.cs | 5 +- src/RdpManager/RestClient.cs | 20 ++++++-- src/RdpManager/RestConsole.xaml | 6 +-- src/RdpManager/RestConsole.xaml.cs | 14 ++++-- src/RdpManager/RestStore.cs | 33 +++++++++++-- src/RdpManager/ServerEditWindow.xaml | 2 +- .../Services/ReachabilityService.cs | 7 +-- src/RdpManager/Services/UpdateService.cs | 18 ++++++- src/RdpManager/SshConnectionFactory.cs | 6 ++- src/RdpManager/Themes/Strings.en.xaml | 1 + src/RdpManager/Themes/Strings.pl.xaml | 1 + src/RdpManager/XtermControl.cs | 37 +++++++++----- 18 files changed, 225 insertions(+), 72 deletions(-) diff --git a/src/RdpManager/Core/AtomicFile.cs b/src/RdpManager/Core/AtomicFile.cs index ac500c0..df34496 100644 --- a/src/RdpManager/Core/AtomicFile.cs +++ b/src/RdpManager/Core/AtomicFile.cs @@ -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); } diff --git a/src/RdpManager/Core/FtpsCertPinning.cs b/src/RdpManager/Core/FtpsCertPinning.cs index 83a7cd8..1cbf519 100644 --- a/src/RdpManager/Core/FtpsCertPinning.cs +++ b/src/RdpManager/Core/FtpsCertPinning.cs @@ -35,8 +35,14 @@ public static Status Check(Dictionary store, string host, int po return string.Equals(known, fingerprint, StringComparison.OrdinalIgnoreCase) ? Status.Match : Status.Mismatch; } - public static Dictionary Load(string dir) + public static Dictionary Load(string dir) => Load(dir, out _); + + /// Jak , ale ustawia =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". + public static Dictionary Load(string dir, out bool storeUnreadable) { + storeUnreadable = false; var path = Path.Combine(dir, "ftps_certs.json"); try { @@ -46,8 +52,9 @@ public static Dictionary 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)); } diff --git a/src/RdpManager/Core/KnownHosts.cs b/src/RdpManager/Core/KnownHosts.cs index 91e6d67..0d1ddfb 100644 --- a/src/RdpManager/Core/KnownHosts.cs +++ b/src/RdpManager/Core/KnownHosts.cs @@ -34,8 +34,15 @@ public static Status Check(Dictionary store, string host, int po return string.Equals(known, fingerprint, StringComparison.Ordinal) ? Status.Match : Status.Mismatch; } - public static Dictionary Load(string dir) + public static Dictionary Load(string dir) => Load(dir, out _); + + /// Jak , ale ustawia =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. + public static Dictionary Load(string dir, out bool storeUnreadable) { + storeUnreadable = false; var path = Path.Combine(dir, "known_hosts.json"); try { @@ -45,8 +52,9 @@ public static Dictionary 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)); } diff --git a/src/RdpManager/Core/PasswordGen.cs b/src/RdpManager/Core/PasswordGen.cs index 50b4b0c..bfbac2d 100644 --- a/src/RdpManager/Core/PasswordGen.cs +++ b/src/RdpManager/Core/PasswordGen.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -19,18 +20,19 @@ 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; + /// Zbiór znaków dla wybranych klas (po ewentualnym wykluczeniu mylących). 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, @@ -38,10 +40,30 @@ public static string GeneratePassword(int length, bool upper, bool lower, bool d { 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(); + 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); } /// Token hex (małe litery), bajtów losowych → 2×hex znaków. diff --git a/src/RdpManager/Core/RdpUtils.cs b/src/RdpManager/Core/RdpUtils.cs index 5ec4d58..47840b8 100644 --- a/src/RdpManager/Core/RdpUtils.cs +++ b/src/RdpManager/Core/RdpUtils.cs @@ -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) diff --git a/src/RdpManager/FileTransferPanel.xaml.cs b/src/RdpManager/FileTransferPanel.xaml.cs index 361342b..f1fd086 100644 --- a/src/RdpManager/FileTransferPanel.xaml.cs +++ b/src/RdpManager/FileTransferPanel.xaml.cs @@ -173,7 +173,9 @@ public async Task TransferInLocalFileAsync(string localPath) /// Pyta raz o nadpisanie, jeśli cel już istnieje lokalnie. public async Task 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; @@ -186,6 +188,17 @@ public async Task 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; } + } + /// 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. public static void UploadTree(IRemoteFs fs, string localPath, string remoteDir, CancellationToken ct) @@ -193,16 +206,18 @@ public static void UploadTree(IRemoteFs fs, string localPath, string remoteDir, // 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 { @@ -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 { @@ -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; } @@ -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; } @@ -350,12 +368,15 @@ await Task.Run(() => private async Task RunTransferAsync(Func computeTotal, Func statusFormat, Action 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; @@ -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); diff --git a/src/RdpManager/FtpFs.cs b/src/RdpManager/FtpFs.cs index 6e25e3b..b1e988b 100644 --- a/src/RdpManager/FtpFs.cs +++ b/src/RdpManager/FtpFs.cs @@ -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; diff --git a/src/RdpManager/RestClient.cs b/src/RdpManager/RestClient.cs index ef7a960..6ea421c 100644 --- a/src/RdpManager/RestClient.cs +++ b/src/RdpManager/RestClient.cs @@ -81,7 +81,7 @@ RestResponse WithSent(RestResponse r) // ResponseHeadersRead (nie ResponseContentRead): sprawdzamy Content-Length ZANIM zaczniemy // czytać ciało — deklarowany-zbyt-duży rozmiar odrzucamy bez pobierania ani bajta. - using (var resp = await Http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct)) + using (var resp = await Http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false)) { long? declared = resp.Content.Headers.ContentLength; if (declared.HasValue && declared.Value > MaxResponseBytes) @@ -91,14 +91,14 @@ RestResponse WithSent(RestResponse r) } byte[] bytes; - using (var stream = await resp.Content.ReadAsStreamAsync(ct)) - bytes = await ReadBoundedAsync(stream, MaxResponseBytes, ct); + using (var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false)) + bytes = await ReadBoundedAsync(stream, MaxResponseBytes, ct).ConfigureAwait(false); sw.Stop(); // Content-Length brakujący/kłamliwy (chunked, proxy) — limit egzekwowany też PODCZAS czytania. if (bytes == null) return WithSent(TooLargeResponse(sw.ElapsedMilliseconds, null)); - string body = Encoding.UTF8.GetString(bytes); + string body = DecodeBody(bytes, resp.Content.Headers.ContentType?.CharSet); var headers = new List>(); foreach (var h in resp.Headers) headers.Add(new KeyValuePair(h.Key, string.Join(", ", h.Value))); foreach (var h in resp.Content.Headers) headers.Add(new KeyValuePair(h.Key, string.Join(", ", h.Value))); @@ -158,6 +158,16 @@ public static List> SnapshotHeaders(HttpRequestMess private static string FormatMb(long bytes) => (bytes / 1048576.0).ToString("0.#") + " MB"; + // Dekoduje ciało odpowiedzi wg charset z Content-Type (np. iso-8859-1); brak/nieznany → UTF-8. + // Nieznane code-page (np. windows-1250 bez zarejestrowanego providera) łapiemy → fallback UTF-8. + private static string DecodeBody(byte[] bytes, string charset) + { + if (!string.IsNullOrWhiteSpace(charset)) + try { return Encoding.GetEncoding(charset.Trim().Trim('"')).GetString(bytes); } + catch { /* nieznany charset → UTF-8 */ } + return Encoding.UTF8.GetString(bytes); + } + /// Czyta strumień do bufora w pamięci, przerywając (null) jeśli przekroczy /// — bez tego bufor rósłby bez ograniczeń dla odpowiedzi bez znanego/wiarygodnego Content-Length /// (chunked, proxy kłamiący o rozmiarze). Publiczne dla testów. @@ -167,7 +177,7 @@ public static async Task ReadBoundedAsync(Stream stream, long maxBytes, { byte[] chunk = new byte[81920]; int read; - while ((read = await stream.ReadAsync(chunk, 0, chunk.Length, ct)) > 0) + while ((read = await stream.ReadAsync(chunk, 0, chunk.Length, ct).ConfigureAwait(false)) > 0) { buffer.Write(chunk, 0, read); if (buffer.Length > maxBytes) return null; diff --git a/src/RdpManager/RestConsole.xaml b/src/RdpManager/RestConsole.xaml index be3f674..2d03df2 100644 --- a/src/RdpManager/RestConsole.xaml +++ b/src/RdpManager/RestConsole.xaml @@ -422,7 +422,7 @@ + FontFamily="{DynamicResource Mono}" FontSize="{StaticResource FontBody}" Padding="8"/> @@ -436,9 +436,9 @@ + FontFamily="{DynamicResource Mono}" FontSize="{StaticResource FontBody}" Margin="0,0,12,0" TextWrapping="Wrap"/> + FontFamily="{DynamicResource Mono}" FontSize="{StaticResource FontBody}" TextWrapping="Wrap"/> diff --git a/src/RdpManager/RestConsole.xaml.cs b/src/RdpManager/RestConsole.xaml.cs index 74494d1..863722a 100644 --- a/src/RdpManager/RestConsole.xaml.cs +++ b/src/RdpManager/RestConsole.xaml.cs @@ -860,19 +860,25 @@ public static Brush MethodBadgeBg(string method) return new SolidColorBrush(Color.FromArgb(0x26, c.R, c.G, c.B)); } + // Kolory JSON z PALETY (per-motyw) — jak MethodBrush. Wcześniej zaszyte odcienie ciemne dawały + // bardzo niski kontrast na jasnym tle Panel (podgląd odpowiedzi REST był nieczytelny w jasnym motywie). private Brush JsonBrush(RestJsonTok k) { switch (k) { - case RestJsonTok.Key: return new SolidColorBrush(Color.FromRgb(0x7B, 0xA6, 0xFF)); - case RestJsonTok.Str: return new SolidColorBrush(Color.FromRgb(0x4B, 0xD6, 0xA0)); - case RestJsonTok.Num: return new SolidColorBrush(Color.FromRgb(0xF0, 0xB4, 0x5F)); - case RestJsonTok.Keyword: return new SolidColorBrush(Color.FromRgb(0xF0, 0xB4, 0x5F)); + case RestJsonTok.Key: return PaletteBrush("MethodPost", 0x7B, 0xA6, 0xFF); + case RestJsonTok.Str: return PaletteBrush("MethodGet", 0x4B, 0xD6, 0xA0); + case RestJsonTok.Num: return PaletteBrush("MethodPut", 0xF0, 0xB4, 0x5F); + case RestJsonTok.Keyword: return PaletteBrush("MethodPut", 0xF0, 0xB4, 0x5F); case RestJsonTok.Punct: return (Brush)TryFindResource("TextTer") ?? Brushes.Gray; default: return (Brush)TryFindResource("TextPrim") ?? Brushes.White; } } + // Pędzel z palety (rozwiązywany per-motyw) z awaryjnym kolorem, gdy zasób nieobecny. + private Brush PaletteBrush(string key, byte r, byte g, byte b) + => (TryFindResource(key) as Brush) ?? new SolidColorBrush(Color.FromRgb(r, g, b)); + // Wstawia treść odpowiedzi do RichTextBox: JSON kolorowany tokenami, reszta jednym kolorem. private void SetResponseBody(string text, bool allowColor) { diff --git a/src/RdpManager/RestStore.cs b/src/RdpManager/RestStore.cs index 9714b41..c162535 100644 --- a/src/RdpManager/RestStore.cs +++ b/src/RdpManager/RestStore.cs @@ -22,20 +22,43 @@ public static class RestStore public static Dictionary Load(string dir) { var path = FilePath(dir); + var main = ReadOrNull(path, preserveCorrupt: true); + + // Self-heal jak w EnvironmentStore/CredentialProfileRepository: plik cofnięty z zewnątrz (.bak + // NOWSZY niż plik — np. rollback AV) przywracamy tylko, gdy .bak ma NIE MNIEJ kolekcji — nie + // wskrzeszamy świadomie usuniętych ani nie nadpisujemy dobrego pliku uboższą kopią. Chroni też + // przed utratą, gdy po uszkodzeniu pliku kolejny Save skopiowałby uszkodzony plik na dobry .bak. + if (AtomicFile.BackupLooksNewer(path)) + { + var bak = ReadOrNull(path + ".bak", preserveCorrupt: false); + if (bak != null && (main == null || bak.Count >= main.Count)) + { + try { File.Copy(path + ".bak", path, overwrite: true); } catch { /* best-effort */ } + return bak; + } + } + return main ?? new Dictionary(); + } + + private static Dictionary ReadOrNull(string p, bool preserveCorrupt) + { try { - if (File.Exists(path)) + if (File.Exists(p)) { - var data = JsonSerializer.Deserialize>(File.ReadAllText(path)); + var data = JsonSerializer.Deserialize>(File.ReadAllText(p)); if (data != null) return data; } } catch { - AtomicFile.PreserveCorrupt(path); // realne kolekcje w uszkodzonym pliku — zachowaj kopię - HealthNotices.Add(HealthNoticeKind.FileQuarantined, Path.GetFileName(path)); + if (preserveCorrupt) + { + AtomicFile.PreserveCorrupt(p); // realne kolekcje w uszkodzonym pliku — zachowaj kopię + HealthNotices.Add(HealthNoticeKind.FileQuarantined, Path.GetFileName(p)); + } } - return new Dictionary(); + return null; } public static void Save(Dictionary data) => Save(data, SettingsStore.Dir); diff --git a/src/RdpManager/ServerEditWindow.xaml b/src/RdpManager/ServerEditWindow.xaml index cf386b6..ecb1bb7 100644 --- a/src/RdpManager/ServerEditWindow.xaml +++ b/src/RdpManager/ServerEditWindow.xaml @@ -255,7 +255,7 @@ - + diff --git a/src/RdpManager/Services/ReachabilityService.cs b/src/RdpManager/Services/ReachabilityService.cs index 263868c..289a1d4 100644 --- a/src/RdpManager/Services/ReachabilityService.cs +++ b/src/RdpManager/Services/ReachabilityService.cs @@ -162,10 +162,11 @@ internal async void DiagnoseServer(ServerInfo server) try { using (var c = new TcpClient()) + using (var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(_probeTimeoutMs))) { - // Task.WaitAsync (nie blokujący wątek WaitOne) — Dispose przy timeout/wyjątku ubija - // wciąż-trwające ConnectAsync pod spodem (zamknięcie gniazda przerywa próbę połączenia). - await c.ConnectAsync(host, port).WaitAsync(TimeSpan.FromMilliseconds(_probeTimeoutMs)); + // Token anuluje SAMO ConnectAsync przy timeout — nie zostaje porzucone, nieobserwowane + // zadanie łączenia (wcześniej WaitAsync porzucał je, a jego późniejszy wyjątek był nieobserwowany). + await c.ConnectAsync(host, port, cts.Token); sw.Stop(); return c.Connected ? (ServerStatus.Online, (int)sw.ElapsedMilliseconds) : (ServerStatus.Offline, -1); } diff --git a/src/RdpManager/Services/UpdateService.cs b/src/RdpManager/Services/UpdateService.cs index fc1c772..b9e1e1b 100644 --- a/src/RdpManager/Services/UpdateService.cs +++ b/src/RdpManager/Services/UpdateService.cs @@ -145,10 +145,24 @@ internal async void CheckUpdatesNow_Click(object sender, RoutedEventArgs e) finally { _updateChecking = false; } } + // Dozwolone hosty pobrania aktualizacji: GitHub i jego CDN assetów. Adres bierzemy z JSON-a wydania, + // więc przed pobraniem i uruchomieniem pliku wymuszamy https + zaufany host. Przy niepodpisanym + // buildzie werdykt CodeSign to CurrentUnsigned („akceptowalny"), więc to jedyna twarda kontrola + // POCHODZENIA pliku — bez niej podmieniony adres (np. http:// albo obcy host) zostałby pobrany. + internal static bool IsTrustedDownloadUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var u)) return false; + if (u.Scheme != Uri.UriSchemeHttps) return false; + string h = u.Host.ToLowerInvariant(); + return h == "github.com" || h.EndsWith(".github.com", StringComparison.Ordinal) + || h == "githubusercontent.com" || h.EndsWith(".githubusercontent.com", StringComparison.Ordinal); + } + internal async void Update_Click(object sender, RoutedEventArgs e) { - // Brak assetu .exe w release → tak jak dawniej: otwórz stronę wydania w przeglądarce. - if (_update == null || string.IsNullOrEmpty(_update.ExeUrl)) + // Brak assetu .exe w release, albo adres spoza zaufanych hostów GitHuba → nie pobieramy w apce; + // otwórz stronę wydania w przeglądarce (użytkownik pobierze ręcznie z zaufanego źródła). + if (_update == null || string.IsNullOrEmpty(_update.ExeUrl) || !IsTrustedDownloadUrl(_update.ExeUrl)) { OpenReleasePage(); return; diff --git a/src/RdpManager/SshConnectionFactory.cs b/src/RdpManager/SshConnectionFactory.cs index a3a6e92..a3ba0cc 100644 --- a/src/RdpManager/SshConnectionFactory.cs +++ b/src/RdpManager/SshConnectionFactory.cs @@ -93,10 +93,12 @@ private void OnHostKey(object sender, HostKeyEventArgs e) bool changed; lock (KnownHosts.Sync) { - var store = KnownHosts.Load(SettingsStore.Dir); + var store = KnownHosts.Load(SettingsStore.Dir, out bool unreadable); var status = KnownHosts.Check(store, _hostKeyHost, _hostKeyPort, fp); if (status == KnownHosts.Status.Match) { e.CanTrust = true; return; } - changed = status == KnownHosts.Status.Mismatch; + // Uszkodzony magazyn → fail-closed: traktuj jak ZMIANĘ klucza (ostrzeżenie, domyślnie + // odrzuć), zamiast cichego „nowy host?" — po uszkodzeniu każdy host wygląda jak nieznany. + changed = status == KnownHosts.Status.Mismatch || unreadable; } var ask = TrustHostKey; diff --git a/src/RdpManager/Themes/Strings.en.xaml b/src/RdpManager/Themes/Strings.en.xaml index 958fbb9..abeaba1 100644 --- a/src/RdpManager/Themes/Strings.en.xaml +++ b/src/RdpManager/Themes/Strings.en.xaml @@ -514,6 +514,7 @@ Format: localPort:host:remotePort (e.g. 5433:db.internal:5432) Delete "{0}"? (directories must be empty) Delete selected ({0})? (directories must be empty) The server returned an unsafe file name: "{0}" — download refused. + The folder tree is too deep (over {0} levels) — transfer stopped (possible symlink loop). Connecting SFTP… Uploading {0}… Downloading {0}… diff --git a/src/RdpManager/Themes/Strings.pl.xaml b/src/RdpManager/Themes/Strings.pl.xaml index 3bd26e1..2b2bdc0 100644 --- a/src/RdpManager/Themes/Strings.pl.xaml +++ b/src/RdpManager/Themes/Strings.pl.xaml @@ -514,6 +514,7 @@ Format: portLokalny:host:portZdalny (np. 5433:db.internal:5432) Usunąć "{0}"? (katalogi tylko puste) Usunąć zaznaczone ({0})? (katalogi tylko puste) Serwer zwrócił niebezpieczną nazwę pliku: „{0}” — pobieranie odrzucone. + Drzewo folderów jest zbyt głębokie (ponad {0} poziomów) — transfer przerwany (możliwa pętla dowiązań). Łączenie SFTP… Wysyłanie {0}… Pobieranie {0}… diff --git a/src/RdpManager/XtermControl.cs b/src/RdpManager/XtermControl.cs index 76a688f..24ddbeb 100644 --- a/src/RdpManager/XtermControl.cs +++ b/src/RdpManager/XtermControl.cs @@ -64,23 +64,34 @@ protected virtual void OnTerminalResize(int cols, int rows) { } if (_ready != null) return await _ready.Task; _ready = new TaskCompletionSource<(int, int)>(TaskCreationOptions.RunContinuationsAsynchronously); - await WaitLoadedAsync(); + try + { + await WaitLoadedAsync(); - // Folder danych WebView2 w %APPDATA%\RdpManager — obok exe może być tylko-do-odczytu. - var env = await CoreWebView2Environment.CreateAsync(null, - Path.Combine(SettingsStore.Dir, "webview2")); - await Web.EnsureCoreWebView2Async(env); + // Folder danych WebView2 w %APPDATA%\RdpManager — obok exe może być tylko-do-odczytu. + var env = await CoreWebView2Environment.CreateAsync(null, + Path.Combine(SettingsStore.Dir, "webview2")); + await Web.EnsureCoreWebView2Async(env); - var s = Web.CoreWebView2.Settings; - s.AreDefaultContextMenusEnabled = false; - s.AreDevToolsEnabled = false; - s.IsStatusBarEnabled = false; - s.IsZoomControlEnabled = false; + var s = Web.CoreWebView2.Settings; + s.AreDefaultContextMenusEnabled = false; + s.AreDevToolsEnabled = false; + s.IsStatusBarEnabled = false; + s.IsZoomControlEnabled = false; - Web.CoreWebView2.WebMessageReceived += OnWebMessage; - Web.CoreWebView2.NavigateToString(BuildHtml()); + Web.CoreWebView2.WebMessageReceived += OnWebMessage; + Web.CoreWebView2.NavigateToString(BuildHtml()); - return await _ready.Task; + return await _ready.Task; + } + catch + { + // Inicjalizacja padła (np. brak runtime WebView2 / zablokowany folder danych). Wyzeruj _ready, + // żeby ponowna próba (przycisk „Połącz ponownie") re-inicjalizowała, zamiast czekać w + // nieskończoność na TaskCompletionSource, który nigdy się nie ukończy. + _ready = null; + throw; + } } // WebView2 tworzy HWND dopiero po wejściu do drzewa — poczekaj na Loaded.