From 887b8982f8a55497fe0c642e15204ab381b3de7c Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:18:48 -0700 Subject: [PATCH] Validate download destination --- src/WingetCreateCore/Common/PackageParser.cs | 11 ++++++- .../WingetCreateTests/TestUtils.cs | 17 ++++++++++ .../UnitTests/PackageParserTests.cs | 31 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/WingetCreateCore/Common/PackageParser.cs b/src/WingetCreateCore/Common/PackageParser.cs index 560c1a81..12f0ee8a 100644 --- a/src/WingetCreateCore/Common/PackageParser.cs +++ b/src/WingetCreateCore/Common/PackageParser.cs @@ -178,7 +178,7 @@ public static async Task DownloadFileAsync(string url, bool allowHttp, l } string urlFile = Path.GetFileName(url.Split('?').Last()); - string contentDispositionFile = response.Content.Headers.ContentDisposition?.FileName?.Trim('"'); + string contentDispositionFile = Path.GetFileName(response.Content.Headers.ContentDisposition?.FileName?.Trim('"')); string requestUrlFileName = Path.GetFileName(response.RequestMessage?.RequestUri?.ToString()); if (!Directory.Exists(InstallerDownloadPath)) @@ -189,6 +189,15 @@ public static async Task DownloadFileAsync(string url, bool allowHttp, l // If no relevant filename can be obtained for the installer download, use a temporary filename as last option. string targetFileName = contentDispositionFile.NullIfEmpty() ?? urlFile.NullIfEmpty() ?? requestUrlFileName.NullIfEmpty() ?? Path.GetTempFileName(); string targetFile = GetNumericFilename(Path.Combine(InstallerDownloadPath, targetFileName)); + + // Defense in depth: ensure the resolved path stays under the installer download directory. + string downloadRoot = Path.GetFullPath(InstallerDownloadPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string fullTargetFile = Path.GetFullPath(targetFile); + if (!fullTargetFile.StartsWith(downloadRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Resolved download path escapes the installer download directory."); + } + using var targetFileStream = File.OpenWrite(targetFile); var contentStream = await response.Content.ReadAsStreamAsync(); diff --git a/src/WingetCreateTests/WingetCreateTests/TestUtils.cs b/src/WingetCreateTests/WingetCreateTests/TestUtils.cs index 99f5e916..0fe143f0 100644 --- a/src/WingetCreateTests/WingetCreateTests/TestUtils.cs +++ b/src/WingetCreateTests/WingetCreateTests/TestUtils.cs @@ -97,6 +97,23 @@ public static void SetMockHttpResponseContent(string installerName) PackageParser.SetHttpMessageHandler(httpMessageHandler); } + /// + /// Sets the mock http response content along with a server-supplied Content-Disposition filename. + /// + /// File name of the installer. + /// The filename value to advertise in the Content-Disposition header. + public static void SetMockHttpResponseContent(string installerName, string contentDispositionFileName) + { + var content = new ByteArrayContent(File.ReadAllBytes(GetTestFile(installerName))); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") + { + FileName = contentDispositionFileName, + }; + httpResponseMessage.Content = content; + PackageParser.SetHttpMessageHandler(httpMessageHandler); + } + /// /// Obtains the relative filepath of the resources test data directory. /// diff --git a/src/WingetCreateTests/WingetCreateTests/UnitTests/PackageParserTests.cs b/src/WingetCreateTests/WingetCreateTests/UnitTests/PackageParserTests.cs index cc8575b1..ec8079ac 100644 --- a/src/WingetCreateTests/WingetCreateTests/UnitTests/PackageParserTests.cs +++ b/src/WingetCreateTests/WingetCreateTests/UnitTests/PackageParserTests.cs @@ -66,6 +66,37 @@ public void ParseExeInstallerFile() ClassicAssert.AreEqual(InstallerType.Exe, manifests.InstallerManifest.Installers.First().InstallerType); } + /// + /// Verifies that a server-supplied Content-Disposition filename containing directory + /// traversal sequences cannot cause the installer to be written outside the intended download + /// directory. + /// + [Test] + public void DownloadFileDispositionStaysInDownloadDirectory() + { + string downloadedPath = null; + try + { + string url = $"https://fakedomain.com/{TestConstants.TestExeInstaller}"; + string invalidFileName = @"..\..\..\..\example.exe"; + TestUtils.SetMockHttpResponseContent(TestConstants.TestExeInstaller, invalidFileName); + + downloadedPath = PackageParser.DownloadFileAsync(url, false).Result; + + string downloadRoot = Path.GetFullPath(PackageParser.InstallerDownloadPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string fullDownloadedPath = Path.GetFullPath(downloadedPath); + Assert.That(fullDownloadedPath, Does.StartWith(downloadRoot + Path.DirectorySeparatorChar), "Downloaded file escaped the installer download directory."); + ClassicAssert.AreEqual("example.exe", Path.GetFileName(downloadedPath)); + } + finally + { + if (downloadedPath != null && File.Exists(downloadedPath)) + { + File.Delete(downloadedPath); + } + } + } + /// /// Downloads the MSI installer file from HTTPS localhost and parses the package to create a manifest object. ///