From b077dd2456ef46712ffd01590dbbbb3911b6a877 Mon Sep 17 00:00:00 2001 From: Anam Navied Date: Fri, 7 Aug 2026 15:13:58 -0400 Subject: [PATCH] Merge pull request #4 from azure-core-compute/msrc-expandarchive-validation --- .../Microsoft.PowerShell.Archive.psm1 | 91 ++++++++++- .../en-US/ArchiveResources.psd1 | 1 + .../Pester.Commands.Cmdlets.Archive.Tests.ps1 | 141 ++++++++++++++++++ 3 files changed, 231 insertions(+), 2 deletions(-) diff --git a/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1 b/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1 index e7dd78d..46926ed 100644 --- a/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1 +++ b/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + data LocalizedData { # culture="en-US" @@ -30,6 +33,13 @@ Import-LocalizedData LocalizedData -filename ArchiveResources -ErrorAction Ignor $zipFileExtension = ".zip" +# Reserved Windows device names used by IsValidWindowsArchiveEntryPath to guard Expand-Archive. +$script:reservedDeviceNames = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList @( + [string[]]@('CON','PRN','AUX','NUL', + 'COM1','COM2','COM3','COM4','COM5','COM6','COM7','COM8','COM9', 'COM¹', 'COM²', 'COM³', + 'LPT1','LPT2','LPT3','LPT4','LPT5','LPT6','LPT7','LPT8','LPT9', 'LPT¹', 'LPT²', 'LPT³'), + [System.StringComparer]::OrdinalIgnoreCase) + <############################################################################################ # The Compress-Archive cmdlet can be used to zip/compress one or more files/directories. ############################################################################################> @@ -920,6 +930,62 @@ function ValidateArchivePathHelper } } +<############################################################################################ +# Get-WindowsArchiveEntryPathValidationResult: Validates a raw archive entry path for Windows +# and returns whether the path should be rejected plus a potentially sanitized path. +# Invalid entries include Win32 device path prefixes (\\.\ \\?\ //./ //?/) and any +# segment containing ':'. Reserved Windows device names in segments are sanitized and prefixed with '_'. +###############################################################################################> +function Get-WindowsArchiveEntryPathValidationResult +{ + param([string] $Path) + + $result = [PSCustomObject]@{ + ContainsInvalidDevicePathPrefix = $false + IsPathModified = $false + UpdatedPath = $Path + } + + if ([string]::IsNullOrEmpty($Path)) + { + return $result + } + + # Colons are illegal in NTFS filenames (i.e NUL:, NUL:stream, file:bad) and must be rejected. + # This method is only called with the entry name and does not contain any drive path (i.e 'C:') + # Also validate against Win32 device path prefixes (\\.\ \\?\ //./ //?/) + if ($Path -match '^(\\\\|//)[.?][/\\]' -or $Path.Contains(':')) + { + $result.ContainsInvalidDevicePathPrefix = $true + return $result + } + + $updatedSegments = New-Object System.Collections.Generic.List[string] + + foreach ($segment in ($Path -split '[/\\]')) + { + $updatedSegment = $segment + $trimmedSegment = $segment.TrimEnd(' .') + # Win32 strips trailing spaces and periods before resolving entries: "NUL " -> "NUL", "NUL." -> "NUL", this trimming should be preserved when validating and renaming entries. + if ($script:reservedDeviceNames.Contains($trimmedSegment)) + { + $updatedSegment = '_' + $trimmedSegment + $result.IsPathModified = $true + } + + $updatedSegments.Add($updatedSegment) + } + + if ($result.IsPathModified) + { + $result.UpdatedPath = [string]::Join([System.IO.Path]::DirectorySeparatorChar, $updatedSegments) + $BadArchiveEntryMessage = ($LocalizedData.ReservedDeviceNameInArchiveEntry -f $Path, $result.UpdatedPath) + Write-Warning $BadArchiveEntryMessage + } + + return $result +} + <############################################################################################ # ExpandArchiveHelper: This is a helper function used to expand the archive file contents # to the specified directory. @@ -990,8 +1056,29 @@ function ExpandArchiveHelper # The archive entries can either be empty directories or files. foreach($currentArchiveEntry in $zipArchive.Entries) { + $entryPath = $currentArchiveEntry.FullName + $isWindowsOS = [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT + + if ($isWindowsOS) + { + # Validate and sanitize the raw entry name before any path resolution. + $pathValidationResult = Get-WindowsArchiveEntryPathValidationResult -Path $entryPath + + # Skip invalid paths with Win32 device path prefixes (\\.\ \\?\ //. //?) + # or segments containing ':'. + if ($pathValidationResult.ContainsInvalidDevicePathPrefix) + { + # if contains device path prefix: skip + $BadArchiveEntryMessage = ($LocalizedData.BadArchiveEntry -f $entryPath) + Write-Error $BadArchiveEntryMessage + continue + } + + $entryPath = $pathValidationResult.UpdatedPath + } + # Windows filesystem provider will internally convert from `/` to `\` - $currentArchiveEntryPath = Join-Path -Path $expandedDir -ChildPath $currentArchiveEntry.FullName + $currentArchiveEntryPath = Join-Path -Path $expandedDir -ChildPath $entryPath # Remove possible relative segments from target # This is similar to [System.IO.Path]::GetFullPath($currentArchiveEntryPath) but uses PS current dir instead of process-wide current dir @@ -1001,7 +1088,7 @@ function ExpandArchiveHelper # Ordinal match is safest, case-sensitive volumes can be mounted within volumes that are case-insensitive. if (-not ($currentArchiveEntryPath.StartsWith($expandedDir, [System.StringComparison]::Ordinal))) { - $BadArchiveEntryMessage = ($LocalizedData.BadArchiveEntry -f $currentArchiveEntry.FullName) + $BadArchiveEntryMessage = ($LocalizedData.BadArchiveEntry -f $entryPath) # notify user of bad archive entry Write-Error $BadArchiveEntryMessage # move on to the next entry in the archive diff --git a/Microsoft.PowerShell.Archive/en-US/ArchiveResources.psd1 b/Microsoft.PowerShell.Archive/en-US/ArchiveResources.psd1 index d3b713d..23d99a7 100644 --- a/Microsoft.PowerShell.Archive/en-US/ArchiveResources.psd1 +++ b/Microsoft.PowerShell.Archive/en-US/ArchiveResources.psd1 @@ -14,6 +14,7 @@ ExpandProgressBarText=The archive file '{0}' expansion is in progress... AppendArchiveFileExtensionMessage=The archive file path '{0}' supplied to the DestinationPath parameter does not include .zip extension. Hence .zip is appended to the supplied DestinationPath path and the archive file would be created at '{1}'. AddItemtoArchiveFile=Adding '{0}'. BadArchiveEntry=Can not process invalid archive entry '{0}'. +ReservedDeviceNameInArchiveEntry=The archive entry '{0}' contains a Windows reserved device name as one of its segments which is not supported. The entry was renamed to '{1}'. CreateFileAtExpandedPath=Created '{0}'. InvalidArchiveFilePathError=The archive file path '{0}' specified as input to the {1} parameter is resolving to multiple file system paths. Provide a unique path to the {2} parameter where the archive file has to be created. InvalidExpandedDirPathError=The directory path '{0}' specified as input to the DestinationPath parameter is resolving to multiple file system paths. Provide a unique path to the Destination parameter where the archive file contents have to be expanded. diff --git a/Tests/Pester.Commands.Cmdlets.Archive.Tests.ps1 b/Tests/Pester.Commands.Cmdlets.Archive.Tests.ps1 index 0545c89..f3c4fe8 100644 --- a/Tests/Pester.Commands.Cmdlets.Archive.Tests.ps1 +++ b/Tests/Pester.Commands.Cmdlets.Archive.Tests.ps1 @@ -617,6 +617,147 @@ Describe "Test suite for Microsoft.PowerShell.Archive module" -Tags "BVT" { catch { $_.FullyQualifiedErrorId | Should Be $expectedError } } + $testCases = @{Entry="CON"}, + @{Entry="PRN"}, + @{Entry="AUX"}, + @{Entry="NUL"}, + @{Entry="NUL "}, + @{Entry="NUL."}, + @{Entry="NUL.."} + @{Entry="COM1"}, + @{Entry="COM2"}, + @{Entry="COM3"}, + @{Entry="COM4"}, + @{Entry="COM5"}, + @{Entry="COM6"}, + @{Entry="COM7"}, + @{Entry="COM8"}, + @{Entry="COM9"}, + @{Entry="COM¹"}, + @{Entry="COM²"}, + @{Entry="COM³"}, + @{Entry="LPT1"}, + @{Entry="LPT2"}, + @{Entry="LPT3"}, + @{Entry="LPT4"}, + @{Entry="LPT5"}, + @{Entry="LPT6"}, + @{Entry="LPT7"}, + @{Entry="LPT8"}, + @{Entry="LPT9"}, + @{Entry="LPT¹"}, + @{Entry="LPT²"}, + @{Entry="LPT³"} + + It "Validate Expand-Archive renames entries using reserved Windows device name ''" -TestCases $testCases -skip:(!$IsWindows) { + param($Entry) + $archivePath = "$TestDrive$($DS)ReservedDeviceNameEntry.zip" + $destinationPath = "$TestDrive$($DS)ReservedDeviceNameEntry" + + Add-CompressionAssemblies + + $archiveFileStreamArgs = @($archivePath, [System.IO.FileMode]::Create) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Create, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + $entry = $zipArchive.CreateEntry($Entry) + $entryStream = $entry.Open() + $entryWriter = New-Object -TypeName System.IO.StreamWriter -ArgumentList $entryStream + $entryWriter.Write("Invalid Entry Content") + $entryWriter.Dispose() + + if ($zipArchive) { $zipArchive.Dispose() } + if ($archiveFileStream) { $archiveFileStream.Dispose() } + + try { + Expand-Archive -Path $archivePath -DestinationPath $destinationPath -WarningVariable WarningVar -WarningAction SilentlyContinue + + $renamedFileName = "_$($Entry.ToString().TrimEnd(' .'))" + $renamedFilePath = Join-Path $destinationPath -ChildPath $renamedFileName + $renamedFileExists = Test-Path $renamedFilePath + $renamedFileExists | Should Be $true + + $WarningVar.Count | Should -Be 1 + $WarningVar[0] | Should Match "Windows reserved device name as one of its segments which is not supported. The entry was renamed" + } + finally + { + Remove-Item -LiteralPath "$TestDrive$($DS)ReservedDeviceNameEntry" -Force -Recurse + } + + } + + $fileNameWithRerservedDeviceNameStemTestCases = @{Entry="NUL.tar.gz"}, + @{Entry="NUL.txt"} + + It "Validate Expand-Archive allows entries that have a Windows reserved device name stem but also include file extension ''" -TestCases $fileNameWithRerservedDeviceNameStemTestCases -skip:(!$IsWindows) { + param($Entry) + $archivePath = "$TestDrive$($DS)ReservedDeviceNameEntry.zip" + $destinationPath = "$TestDrive$($DS)ReservedDeviceNameEntry" + + Add-CompressionAssemblies + + $archiveFileStreamArgs = @($archivePath, [System.IO.FileMode]::Create) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Create, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + $entry = $zipArchive.CreateEntry($Entry) + $entryStream = $entry.Open() + $entryWriter = New-Object -TypeName System.IO.StreamWriter -ArgumentList $entryStream + $entryWriter.Write("Valid Entry Content") + $entryWriter.Dispose() + + if ($zipArchive) { $zipArchive.Dispose() } + if ($archiveFileStream) { $archiveFileStream.Dispose() } + + Expand-Archive -Path $archivePath -DestinationPath $destinationPath -WarningVariable WarningVar -WarningAction SilentlyContinue + $archiveEntryFilePath = Join-Path $destinationPath -ChildPath "$Entry" + $archiveEntryFileExists = Test-Path $archiveEntryFilePath + $archiveEntryFileExists | Should Be $true + + $WarningVar.Count | Should -Be 0 + } + + $win32DevicePathPrefixTestCases = @( + @{ Entry = "\\.\file1.txt" }, + @{ Entry = "\\?\$TestDrive$($DS)file1.txt" }, + @{ Entry = "//./file1.txt" }, + @{ Entry = "//?/$TestDrive$($DS)file1.txt" } + ) + + It "Validate Expand-Archive rejects zip entries using Win32 device path prefix " -TestCases $win32DevicePathPrefixTestCases -skip:(!$IsWindows) { + param($Entry) + $archivePath = "$TestDrive$($DS)Win32DevicePathPrefixEntry.zip" + $destinationPath = "$TestDrive$($DS)Win32DevicePathPrefixEntry" + + Add-CompressionAssemblies + + $archiveFileStreamArgs = @($archivePath, [System.IO.FileMode]::Create) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Create, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + $entry = $zipArchive.CreateEntry($Entry) + $entryStream = $entry.Open() + $entryWriter = New-Object -TypeName System.IO.StreamWriter -ArgumentList $entryStream + $entryWriter.Write("Invalid Entry Content") + $entryWriter.Dispose() + + if ($zipArchive) { $zipArchive.Dispose() } + if ($archiveFileStream) { $archiveFileStream.Dispose() } + + $res = Test-Path $archivePath + $res | Should Be $true + Expand-Archive -Path $archivePath -DestinationPath $destinationPath -ErrorAction SilentlyContinue -ErrorVariable err + $err.Count | Should -Be 1 + $err[0].Exception.Message | Should Match "invalid archive entry" + } + It "Validate that you can compress an archive to a custom PSDrive using the Compress-Archive cmdlet" { $sourcePath = "$TestDrive$($DS)SourceDir$($DS)ChildDir-1$($DS)Sample-3.txt" $destinationDriveName = 'CompressArchivePesterTest'