Skip to content

Commit 2d7568d

Browse files
JusterZhuclaude
andauthored
fix(core): resolve path traversal, process launch safety, null-safety, OOM guard, and IPC key validation (#516)
- C1: GetTempDirectory() use StartsWith+Substring instead of string.Replace for path computation to prevent incorrect path resolution when targetPath is a substring of another path component (potential path traversal). - H1: DiffPipelineOptions.MaxInputFileSize guard — configurable file size limit checked before invoking the binary differ, preventing OOM on oversized files. Default 0 = no limit (backward compatible). - H2: Validate Process.Start return value across all strategy files (Windows/Linux/Mac/Client/Abstract). Log PID on success, throw on null. - H3: UnixPermissionHooks uses ArgumentList { "+x", mainApp } on .NET 6+ to avoid shell injection; netstandard2.0 fallback retains quoting. - H4: SafeOnBeforeUpdateAsync returns false on hook exception so a faulty hook does not silently allow the update to proceed. - L4: Environments.Set/GetEnvironmentVariable validates key contains only alphanumeric, underscore, hyphen, and dot chars to prevent path traversal. - M2: HttpClientProvider sets 5-minute hard upper bound timeout as safety net instead of InfiniteTimeSpan. Improve comment clarity. - M3: StorageManager.GetAllFiles and CopyDirectory use dirName.StartsWith instead of dirName.Contains for directory skipping. - M4: GetTempDirectory and GetBackupDirectoryName use DateTime.UtcNow. - M6: SilentPollOrchestrator dispatches ExceptionEventArgs via EventManager. - M7: DefaultDirtyMatcher.Match adds ArgumentNullException guard. - M8: GracefulExit.ShutdownAsync adds XML doc explaining CloseMainWindow() behavior for console / headless processes. - L1: CopyUnknownFiles skips files outside patch directory instead of using the full absolute path. Fix comment wording. - L2: AbstractStrategy: patchRoot in finally; sanitize version.Name as subdirectory key (null-safe, strip path separators, guard . / ..). - L5: Remove redundant File.Delete in EncryptedFileProcessContractProvider. - L7: CallSmallBowlHomeAsync logs a warning when Bowl name is empty. Co-authored-by: Claude <noreply@anthropic.com>
1 parent a40123c commit 2d7568d

16 files changed

Lines changed: 219 additions & 35 deletions

src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,11 @@ private void InitBlackPolicy()
577577

578578
private async Task CallSmallBowlHomeAsync(string processName)
579579
{
580-
if (string.IsNullOrWhiteSpace(processName)) return;
580+
if (string.IsNullOrWhiteSpace(processName))
581+
{
582+
GeneralTracer.Warn("CallSmallBowlHomeAsync: Bowl process name is empty or whitespace, skipping shutdown.");
583+
return;
584+
}
581585
try
582586
{
583587
var processes = Process.GetProcessesByName(processName);

src/c#/GeneralUpdate.Core/Configuration/Environments.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System;
12
using System.IO;
3+
using System.Linq;
24
using System.Security.Cryptography;
35
using System.Text;
46
using GeneralUpdate.Core.Ipc;
@@ -17,6 +19,11 @@ public static class Environments
1719
.ComputeHash(Encoding.UTF8.GetBytes("GeneralUpdate.IPC.EnvironmentProvider.v1"));
1820
private static readonly byte[] _aesIV = new byte[16] { 0x47, 0x55, 0x50, 0x44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
1921

22+
/// <summary>
23+
/// Allowed characters for IPC key names: alphanumeric, underscore, hyphen, dot.
24+
/// </summary>
25+
private static readonly char[] KeyAllowedChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-.".ToCharArray();
26+
2027
private static string IpcDir
2128
{
2229
get
@@ -29,15 +36,27 @@ private static string IpcDir
2936

3037
public static void SetEnvironmentVariable(string key, string value)
3138
{
39+
ValidateKey(key);
3240
var filePath = Path.Combine(IpcDir, $"{key}.enc");
3341
var plainBytes = Encoding.UTF8.GetBytes(value);
3442
IpcEncryption.EncryptToFile(plainBytes, filePath, _aesKey, _aesIV);
3543
}
3644

3745
public static string GetEnvironmentVariable(string key)
3846
{
47+
ValidateKey(key);
3948
var filePath = Path.Combine(Path.GetTempPath(), "GeneralUpdate", "ipc", $"{key}.enc");
4049
var plainBytes = IpcEncryption.DecryptFromFile(filePath, _aesKey, _aesIV);
4150
return plainBytes != null ? Encoding.UTF8.GetString(plainBytes) : string.Empty;
4251
}
52+
53+
private static void ValidateKey(string key)
54+
{
55+
if (string.IsNullOrWhiteSpace(key))
56+
throw new ArgumentException("IPC key must not be null or whitespace.", nameof(key));
57+
if (key.Any(c => !KeyAllowedChars.Contains(c)))
58+
throw new ArgumentException(
59+
$"IPC key '{key}' contains invalid characters. Only alphanumeric, underscore, hyphen, and dot are allowed.",
60+
nameof(key));
61+
}
4362
}

src/c#/GeneralUpdate.Core/Differential/DefaultDirtyMatcher.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.IO;
34
using System.Linq;
@@ -33,6 +34,9 @@ public class DefaultDirtyMatcher : IDirtyMatcher
3334
/// <inheritdoc/>
3435
public FileInfo? Match(FileInfo oldFile, IEnumerable<FileInfo> patchFiles)
3536
{
37+
if (oldFile is null) throw new ArgumentNullException(nameof(oldFile));
38+
if (patchFiles is null) throw new ArgumentNullException(nameof(patchFiles));
39+
3640
var findFile = patchFiles.FirstOrDefault(f =>
3741
{
3842
var name = Path.GetFileNameWithoutExtension(f.Name);

src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ public static void CreateJson<T>(string targetPath, T obj, JsonTypeInfo<T>? type
199199
/// </remarks>
200200
public static string GetTempDirectory(string name)
201201
{
202-
var path = $"generalupdate_{DateTime.Now:yyyy-MM-dd-HHmmss-fff}_{System.Diagnostics.Process.GetCurrentProcess().Id}_{name}";
202+
var path = $"generalupdate_{DateTime.UtcNow:yyyy-MM-dd-HHmmss-fff}_{System.Diagnostics.Process.GetCurrentProcess().Id}_{name}";
203203
var tempDir = Path.Combine(Path.GetTempPath(), path);
204204
if (!Directory.Exists(tempDir))
205205
{
@@ -217,7 +217,7 @@ public static string GetTempDirectory(string name)
217217
/// Timestamp naming ensures each backup is unique and naturally sortable by creation time.
218218
/// Used by <see cref="Backup"/> to create version-independent backup directory names.
219219
/// </remarks>
220-
public static string GetBackupDirectoryName() => $"{DirectoryName}{DateTime.Now:yyyyMMddHHmmss}";
220+
public static string GetBackupDirectoryName() => $"{DirectoryName}{DateTime.UtcNow:yyyyMMddHHmmss}";
221221

222222
/// <summary>
223223
/// Finds the most recent backup directory by scanning for backup directories
@@ -317,7 +317,11 @@ public static List<FileInfo> GetAllFiles(string path, List<string> skipDirectory
317317
bool shouldSkip = false;
318318
foreach (var notBackup in skipDirectorys)
319319
{
320-
if (dic.FullName.Contains(notBackup))
320+
// Use prefix matching instead of substring Contains to avoid
321+
// false-positive skips (e.g. a directory named "myapp-backup-config"
322+
// should not be skipped just because "backup-" appears in its name).
323+
var dirName = dic.Name;
324+
if (dirName.StartsWith(notBackup, StringComparison.OrdinalIgnoreCase))
321325
{
322326
shouldSkip = true;
323327
break;
@@ -425,7 +429,7 @@ private static void CopyDirectory(string sourceDir, string targetDir, IReadOnlyL
425429
foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.TopDirectoryOnly))
426430
{
427431
var dirName = Path.GetFileName(dirPath);
428-
if (!directoryNames.Any(name => dirName.Contains(name)))
432+
if (!directoryNames.Any(name => dirName.StartsWith(name, StringComparison.OrdinalIgnoreCase)))
429433
{
430434
string newTargetDir = Path.Combine(targetDir, Path.GetFileName(dirPath));
431435
Directory.CreateDirectory(newTargetDir);

src/c#/GeneralUpdate.Core/GracefulExit.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,32 @@ public static class GracefulExit
99
/// <summary>
1010
/// Attempt a graceful shutdown via CloseMainWindow(), fall back to Kill() after timeout.
1111
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// <see cref="Process.CloseMainWindow"/> only works for GUI processes with a main window.
15+
/// For console applications or headless processes, it returns <c>false</c> immediately.
16+
/// In that case the method waits <paramref name="timeoutMs"/> and then falls back to
17+
/// <see cref="Process.Kill"/> as a last resort.
18+
/// </para>
19+
/// <para>
20+
/// When <c>CloseMainWindow</c> succeeds (returns <c>true</c>), the close message was
21+
/// sent to the main window's message queue. The process is given <paramref name="timeoutMs"/>
22+
/// to exit before falling back to <c>Kill</c>.
23+
/// </para>
24+
/// </remarks>
1225
public static async Task ShutdownAsync(Process? process, int timeoutMs = 3000)
1326
{
1427
if (process == null || process.HasExited) return;
15-
if (!process.CloseMainWindow())
28+
if (process.CloseMainWindow())
29+
{
30+
// CloseMainWindow sent a WM_CLOSE — give the process time to shut down.
1631
await Task.Delay(timeoutMs).ConfigureAwait(false);
32+
}
33+
else
34+
{
35+
// Process has no main window (e.g. console app) — still give it time.
36+
await Task.Delay(timeoutMs).ConfigureAwait(false);
37+
}
1738
if (!process.HasExited)
1839
process.Kill(); // Last resort
1940
}

src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,37 @@ public class UnixPermissionHooks : IUpdateHooks
4848
public async Task OnBeforeStartAppAsync(HookContext ctx)
4949
{
5050
var mainApp = Path.Combine(ctx.InstallPath, ctx.UpdateAppName);
51-
if (File.Exists(mainApp))
52-
await Task.Run(() => Process.Start("chmod", $"+x \"{mainApp}\"").WaitForExit());
51+
if (!File.Exists(mainApp)) return;
52+
53+
var exitCode = -1;
54+
#if NET6_0_OR_GREATER
55+
// Use ArgumentList to avoid shell injection via user-controlled path.
56+
// NOTE: when ArgumentList is non-empty, ProcessStartInfo.Arguments is
57+
// ignored, so the mode "+x" must be added to ArgumentList as well.
58+
var psi = new ProcessStartInfo("chmod")
59+
{
60+
ArgumentList = { "+x", mainApp },
61+
RedirectStandardOutput = true,
62+
RedirectStandardError = true
63+
};
64+
using var proc = Process.Start(psi);
65+
if (proc == null)
66+
{
67+
GeneralTracer.Warn($"UnixPermissionHooks: chmod process could not be started for {mainApp}.");
68+
return;
69+
}
70+
await Task.Run(() => proc.WaitForExit());
71+
exitCode = proc.ExitCode;
72+
#else
73+
// netstandard2.0 fallback: path is double-quoted to mitigate injection risk.
74+
// ArgumentList is unavailable, so we rely on the quoting and the fact that
75+
// chmod +x with an extra argument is not exploitable beyond a file-not-found error.
76+
using var proc = Process.Start("chmod", $"+x \"{mainApp}\"");
77+
await Task.Run(() => proc.WaitForExit());
78+
exitCode = proc.ExitCode;
79+
#endif
80+
if (exitCode != 0)
81+
GeneralTracer.Warn($"UnixPermissionHooks: chmod for {mainApp} exited with code {exitCode}.");
5382
}
5483
public Task<bool> OnBeforeUpdateAsync(HookContext ctx) => Task.FromResult(true);
5584
public Task OnDownloadCompletedAsync(DownloadContext ctx) => Task.CompletedTask;

src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ public void Send(ProcessContract info)
7979
var plain = IpcEncryption.DecryptFromFile(_filePath, Key, IV);
8080
if (plain == null) return null;
8181

82-
try { File.Delete(_filePath); }
83-
catch { /* best-effort cleanup */ }
82+
// Note: IpcEncryption.DecryptFromFile deletes the file in its finally block,
83+
// so no additional cleanup is needed here.
8484

8585
var json = Encoding.UTF8.GetString(plain);
8686
return JsonSerializer.Deserialize(json, ProcessContractJsonContext.Default.ProcessContract);

src/c#/GeneralUpdate.Core/Network/HttpClientProvider.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ static HttpClientProvider()
3434
_sslPolicy.ValidateCertificate(cert, chain, errors);
3535
_shared = new HttpClient(handler, disposeHandler: false)
3636
{
37-
// Let per-request CancellationTokenSource.CancelAfter (set by
38-
// HttpDownloadExecutor & VersionService) control timeout — the
39-
// global HttpClient.Timeout (default ~100s) would otherwise cap
40-
// requests before a caller-configured longer DownloadTimeout.
41-
Timeout = System.Threading.Timeout.InfiniteTimeSpan
37+
// Default 5-minute hard upper bound as a safety net. Per-request
38+
// CancellationTokenSource.CancelAfter (set by HttpDownloadExecutor
39+
// and VersionService) provides the primary timeout — this value
40+
// only catches leaked requests where no per-request timeout was set.
41+
// Callers requiring transfers longer than 5 minutes must increase
42+
// this timeout or pass a larger value via e.g. UpdateRequest.
43+
Timeout = TimeSpan.FromMinutes(5)
4244
};
4345
}
4446

src/c#/GeneralUpdate.Core/Pipeline/DiffPipeline.cs

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ public async Task CleanAsync(
239239
{
240240
if (!StorageManager.HashEquals(oldFile.FullName, file.FullName))
241241
{
242+
ValidateFileSize(oldFile.FullName, file.FullName);
242243
var tempPatchPath = Path.Combine(tempDir, $"{file.Name}{PatchExtension}");
243244
await _binaryDiffer.CleanAsync(oldFile.FullName, file.FullName, tempPatchPath, cancellationToken);
244245
}
@@ -522,14 +523,20 @@ private static Task CopyUnknownFiles(string appPath, string patchPath)
522523

523524
// Compute the relative path by stripping the patch directory prefix.
524525
// Using StartsWith + Substring instead of string.Replace to avoid
525-
// incorrect replacements when appPath is a substring of patchPath.
526+
// incorrect replacements when the prefix appears as a substring of
527+
// another path component (e.g. "C:\target" vs "C:\target-extra").
526528
var patchPrefix = patchPath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
527529
var comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
528530
? StringComparison.OrdinalIgnoreCase
529531
: StringComparison.Ordinal;
530532
var relativePart = file.FullName.StartsWith(patchPrefix, comparison)
531533
? file.FullName.Substring(patchPrefix.Length)
532-
: file.FullName;
534+
: null;
535+
if (relativePart == null)
536+
{
537+
GeneralTracer.Warn($"CopyUnknownFiles: file {file.FullName} is outside patch directory {patchPath}, skipping.");
538+
continue;
539+
}
533540
var targetPath = Path.Combine(appPath, relativePart);
534541
var parentFolder = Directory.GetParent(targetPath);
535542
if (parentFolder?.Exists == false)
@@ -572,10 +579,18 @@ private static Task CopyUnknownFiles(string appPath, string patchPath)
572579
/// </remarks>
573580
private static string GetTempDirectory(FileNode file, string targetPath, string patchPath)
574581
{
575-
var tempPath = file.FullName
576-
.Replace(targetPath, "")
577-
.Replace(Path.GetFileName(file.FullName), "")
578-
.Trim(Path.DirectorySeparatorChar);
582+
// Use StartsWith + Substring instead of string.Replace to avoid incorrect
583+
// replacements when targetPath is a substring of another path component.
584+
// Same approach as CopyUnknownFiles which documents this rationale.
585+
var prefix = targetPath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
586+
var comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
587+
? StringComparison.OrdinalIgnoreCase
588+
: StringComparison.Ordinal;
589+
var tempPath = file.FullName.StartsWith(prefix, comparison)
590+
? file.FullName.Substring(prefix.Length)
591+
: string.Empty;
592+
if (!string.IsNullOrEmpty(tempPath))
593+
tempPath = tempPath.Replace(Path.GetFileName(file.FullName), "").Trim(Path.DirectorySeparatorChar);
579594
var tempDir = string.IsNullOrEmpty(tempPath) ? patchPath : Path.Combine(patchPath, tempPath);
580595
Directory.CreateDirectory(tempDir);
581596
return tempDir;
@@ -604,4 +619,26 @@ private static void ValidateDirectories(string sourcePath, string targetPath, st
604619
if (!Directory.Exists(targetPath))
605620
throw new DirectoryNotFoundException($"Target directory not found: {targetPath}");
606621
}
622+
623+
/// <summary>
624+
/// Validates that both source and target files do not exceed the configured <see cref="DiffPipelineOptions.MaxInputFileSize"/>.
625+
/// The BSDIFF algorithm loads entire files into memory, so this guard prevents <see cref="OutOfMemoryException"/>
626+
/// when processing oversized files.
627+
/// </summary>
628+
private void ValidateFileSize(string oldFilePath, string newFilePath)
629+
{
630+
if (_options.MaxInputFileSize <= 0) return;
631+
632+
var oldInfo = new FileInfo(oldFilePath);
633+
if (oldInfo.Length > _options.MaxInputFileSize)
634+
throw new InvalidOperationException(
635+
$"File too large for binary differ: {oldFilePath} ({oldInfo.Length} bytes > {_options.MaxInputFileSize} bytes limit). " +
636+
$"Increase {nameof(DiffPipelineOptions.MaxInputFileSize)} or switch to a streaming differ.");
637+
638+
var newInfo = new FileInfo(newFilePath);
639+
if (newInfo.Length > _options.MaxInputFileSize)
640+
throw new InvalidOperationException(
641+
$"File too large for binary differ: {newFilePath} ({newInfo.Length} bytes > {_options.MaxInputFileSize} bytes limit). " +
642+
$"Increase {nameof(DiffPipelineOptions.MaxInputFileSize)} or switch to a streaming differ.");
643+
}
607644
}

src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineOptions.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ namespace GeneralUpdate.Core.Pipeline;
1414
/// <list type="bullet">
1515
/// <item><description><see cref="MaxDegreeOfParallelism"/> = 2 — Processes 2 files concurrently.</description></item>
1616
/// <item><description><see cref="StopOnFirstError"/> = <c>false</c> — Continues processing other files on error.</description></item>
17+
/// <item><description><see cref="MaxInputFileSize"/> = 0 — No limit (backward compatible).</description></item>
1718
/// </list>
1819
/// </para>
1920
/// </remarks>
@@ -63,4 +64,27 @@ public sealed class DiffPipelineOptions
6364
/// </para>
6465
/// </remarks>
6566
public bool StopOnFirstError { get; set; } = false;
67+
68+
/// <summary>
69+
/// Gets or sets the maximum allowed file size (in bytes) for files processed by the binary differ.
70+
/// Files larger than this threshold cause an error rather than being loaded entirely into memory.
71+
/// </summary>
72+
/// <value>
73+
/// The maximum file size in bytes. The default value is 0, meaning no limit (backward compatible).
74+
/// Set to a positive value to reject oversized files before the differ allocates memory.
75+
/// Example: 512MB = 512 * 1024 * 1024.
76+
/// </value>
77+
/// <remarks>
78+
/// <para>
79+
/// The BSDIFF algorithm reads entire files into memory (old + new + up to 3x new file size in
80+
/// working buffers). For large files this can cause <see cref="OutOfMemoryException"/>.
81+
/// When <see cref="MaxInputFileSize"/> is set, the pipeline checks both old and new file sizes
82+
/// before invoking the differ and reports a clear error for oversized files.
83+
/// </para>
84+
/// <para>
85+
/// For incremental updates of very large files, consider splitting the file into smaller chunks
86+
/// or using an alternative differ with streaming support.
87+
/// </para>
88+
/// </remarks>
89+
public long MaxInputFileSize { get; set; } = 0;
6690
}

0 commit comments

Comments
 (0)