Skip to content
Open
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/WingetCreateCore/Common/PackageParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ public static async Task<string> 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))
Expand All @@ -189,6 +189,15 @@ public static async Task<string> 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();

Expand Down
17 changes: 17 additions & 0 deletions src/WingetCreateTests/WingetCreateTests/TestUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ public static void SetMockHttpResponseContent(string installerName)
PackageParser.SetHttpMessageHandler(httpMessageHandler);
}

/// <summary>
/// Sets the mock http response content along with a server-supplied Content-Disposition filename.
/// </summary>
/// <param name="installerName">File name of the installer.</param>
/// <param name="contentDispositionFileName">The filename value to advertise in the Content-Disposition header.</param>
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);
}

/// <summary>
/// Obtains the relative filepath of the resources test data directory.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ public void ParseExeInstallerFile()
ClassicAssert.AreEqual(InstallerType.Exe, manifests.InstallerManifest.Installers.First().InstallerType);
}

/// <summary>
/// Verifies that a server-supplied Content-Disposition filename containing directory
/// traversal sequences cannot cause the installer to be written outside the intended download
/// directory.
/// </summary>
[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);
}
}
}

/// <summary>
/// Downloads the MSI installer file from HTTPS localhost and parses the package to create a manifest object.
/// </summary>
Expand Down
Loading