Private/Test-InforcerSafeOutputPath.ps1
|
function Test-InforcerSafeOutputPath { <# .SYNOPSIS Refuses output paths that point at known OS / system directories (Private helper). .DESCRIPTION Defense-in-depth against server-controlled filenames + user-supplied -OutputPath combining to write into protected locations. The Reports API's Content-Disposition filename is sanitized to a leaf name (no traversal), but if the user passes -OutputPath /etc and the server returns filename="passwd", we should refuse. Compares the canonical (resolved) path against a per-platform deny list. Returns $true when the path is safe; throws a terminating error when it isn't. Out of scope: this is not a full ACL check. We don't try to determine whether the user has write permission to the directory — only whether the directory is one we know the module should never write to. .PARAMETER Path Directory the user passed via -OutputPath. May not exist yet. .OUTPUTS System.Boolean — always $true on success; throws otherwise. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [string]$Path ) if ([string]::IsNullOrWhiteSpace($Path)) { return $true } # Resolve to canonical absolute path. GetFullPath handles `..` and symlinks-as-text but # does NOT follow filesystem symlinks; that's adequate for our deny check (caller can't # use `..` to escape the leaf restriction since the path goes through GetFullPath). $resolved = $null try { $resolved = [System.IO.Path]::GetFullPath($Path) } catch { # Malformed paths fall through — let downstream creation logic surface the error. return $true } if ([string]::IsNullOrWhiteSpace($resolved)) { return $true } $normalized = $resolved.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) # Platform-aware deny prefixes. On Windows we resolve %WINDIR% / %PROGRAMFILES% from env. $denyPrefixes = @() if ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { foreach ($envName in 'WINDIR','SystemRoot','PROGRAMFILES','PROGRAMFILES(X86)','PROGRAMDATA') { $v = [System.Environment]::GetEnvironmentVariable($envName) if ($v) { $denyPrefixes += $v.TrimEnd('\','/') } } # Plus the fixed roots. $denyPrefixes += @('C:\Windows','C:\Program Files','C:\Program Files (x86)','C:\ProgramData') } else { # macOS / Linux — block system directories. Don't block /tmp or /var/folders. $denyPrefixes += @('/etc','/usr','/bin','/sbin','/boot','/lib','/lib32','/lib64','/System','/Library/System') } foreach ($deny in $denyPrefixes) { if ([string]::IsNullOrWhiteSpace($deny)) { continue } $denyNorm = $deny.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) # Case-insensitive on Windows, case-sensitive on Unix — match OS behavior. $cmp = if ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } if ($normalized.StartsWith($denyNorm, $cmp) -and ($normalized.Length -eq $denyNorm.Length -or $normalized[$denyNorm.Length] -eq [System.IO.Path]::DirectorySeparatorChar -or $normalized[$denyNorm.Length] -eq [System.IO.Path]::AltDirectorySeparatorChar)) { throw "Refusing to write under '$denyNorm' (system / OS directory). Choose a different -OutputPath." } } return $true } |