& { # Download this script from your approved internal host, inspect it, then run it locally. [CmdletBinding()] param( [uri]$ManifestUrl, [string]$Destination = (Join-Path $env:LOCALAPPDATA 'Xenial Events\Laptop Setup'), [Security.SecureString]$AccessToken, [switch]$NoLaunch, [switch]$LibraryOnly ) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem Add-Type -AssemblyName System.Net.Http function Assert-DownloadUrl([uri]$Url) { if (-not $Url.IsAbsoluteUri -or $Url.Scheme -ne 'https' -or $Url.UserInfo -or $Url.Fragment) { throw 'Downloads require HTTPS URLs without embedded credentials or fragments.' } } function Test-DownloadTokenScope([uri]$Url, [uri]$Authority) { return ($Authority -and $Url.Scheme -eq 'https' -and $Url.Authority.Equals($Authority.Authority, [StringComparison]::OrdinalIgnoreCase)) } function New-DownloadRequest([uri]$Url, [uri]$Authority, [Security.SecureString]$Token, [string]$Accept = 'application/json') { Assert-DownloadUrl $Url $request = New-Object Net.Http.HttpRequestMessage([Net.Http.HttpMethod]::Get, $Url) $request.Headers.UserAgent.ParseAdd('LaptopSetupApp-Bootstrap/1.1') $request.Headers.Accept.ParseAdd($Accept) if ($Token -and (Test-DownloadTokenScope $Url $Authority)) { $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Token) try { $request.Headers.Authorization = New-Object Net.Http.Headers.AuthenticationHeaderValue('Bearer', [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } return $request } function Resolve-ReleaseAssetUrl($Release, [uri]$ReleaseUrl, [string]$Name, [string]$BrowserUrl) { $matches = @($Release.assets | Where-Object { if ($BrowserUrl) { $_.browser_download_url -ceq $BrowserUrl -or $_.url -ceq $BrowserUrl } else { $_.name -ceq $Name } }) if ($matches.Count -ne 1) { throw 'Release must contain exactly one matching asset.' } $assetUrl = [uri]$matches[0].url Assert-DownloadUrl $assetUrl $repositoryPath = $ReleaseUrl.AbsolutePath -replace '/releases/latest/?$', '' if (-not (Test-DownloadTokenScope $assetUrl $ReleaseUrl) -or $assetUrl.AbsolutePath -notmatch ('^' + [regex]::Escape($repositoryPath) + '/releases/assets/\d+$')) { throw 'Release asset URL is outside the expected repository API.' } return $assetUrl } function Save-BoundedDownload([uri]$Url, [string]$Path, [long]$MaximumBytes, [uri]$Authority, [Security.SecureString]$Token, [string]$Accept = 'application/json') { Assert-DownloadUrl $Url $handler = New-Object Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $false $client = New-Object Net.Http.HttpClient($handler) $client.Timeout = [TimeSpan]::FromMinutes(30) $response = $null; $inputStream = $null; $outputStream = $null try { for ($redirect = 0; ; $redirect++) { $request = New-DownloadRequest $Url $Authority $Token $Accept try { $response = $client.SendAsync($request, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() } finally { $request.Dispose() } if ([int]$response.StatusCode -notin @(301, 302, 303, 307, 308)) { break } if ($redirect -ge 5 -or -not $response.Headers.Location) { throw 'Download exceeded the redirect limit or supplied an invalid redirect.' } $Url = New-Object uri($Url, $response.Headers.Location) Assert-DownloadUrl $Url $response.Dispose(); $response = $null } $response.EnsureSuccessStatusCode() | Out-Null if ($response.Content.Headers.ContentLength -gt $MaximumBytes) { throw 'Download exceeds the size limit.' } $inputStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() $outputStream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew) $buffer = New-Object byte[] 81920 [long]$total = 0 $deadline = [DateTime]::UtcNow.AddMinutes(30) while ($true) { if ([DateTime]::UtcNow -gt $deadline) { throw 'Download timed out.' } $readTask = $inputStream.ReadAsync($buffer, 0, $buffer.Length) if (-not $readTask.Wait(60000)) { throw 'Download stalled.' } $count = $readTask.GetAwaiter().GetResult() if ($count -eq 0) { break } $total += $count if ($total -gt $MaximumBytes) { throw 'Download exceeds the size limit.' } $outputStream.Write($buffer, 0, $count) } } finally { if ($outputStream) { $outputStream.Dispose() }; if ($inputStream) { $inputStream.Dispose() } if ($response) { $response.Dispose() }; $client.Dispose(); $handler.Dispose() } } function Expand-VerifiedAppPackage([string]$ZipPath, [string]$Sha256, [string]$Target) { if ($Sha256 -notmatch '^[a-fA-F0-9]{64}$' -or (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash -ne $Sha256) { throw 'Package SHA256 verification failed.' } if (Test-Path -LiteralPath $Target) { throw 'Extraction target already exists.' } $archive = [IO.Compression.ZipFile]::OpenRead($ZipPath) try { $seen = New-Object 'Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) [long]$expandedBytes = 0 if ($archive.Entries.Count -gt 20000) { throw 'Archive has too many entries.' } foreach ($entry in $archive.Entries) { $name = $entry.FullName.Replace('\', '/') $parts = $name.TrimEnd('/').Split('/') if (-not $name -or $name.StartsWith('/') -or $name.Contains(':') -or $name -match '[\x00-\x1f<>"|?*]' -or ($parts | Where-Object { $_ -eq '..' -or $_ -eq '.' -or $_ -eq '' -or $_ -match '[ .]$' -or $_ -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)' })) { throw "Unsafe archive path: $name" } if (-not $seen.Add($name.TrimEnd('/'))) { throw "Duplicate archive path: $name" } $mode = ($entry.ExternalAttributes -shr 16) -band 0xF000 if ($mode -eq 0xA000 -or ($entry.ExternalAttributes -band 0x400)) { throw 'Archive links are not allowed.' } $expandedBytes += $entry.Length if ($expandedBytes -gt 2GB) { throw 'Expanded archive exceeds 2 GB.' } } foreach ($required in @('LaptopSetupApp.exe', 'LaptopSetupApp.dll')) { $fileEntry = @($archive.Entries | Where-Object { $_.FullName -ieq $required }) if ($fileEntry.Count -ne 1 -or $fileEntry[0].Length -eq 0) { throw "Package is missing $required at its root." } } } finally { $archive.Dispose() } [IO.Compression.ZipFile]::ExtractToDirectory($ZipPath, $Target) } if ($LibraryOnly) { return } if (-not $ManifestUrl) { throw 'Provide -ManifestUrl with the approved direct HTTPS manifest URL.' } Assert-DownloadUrl $ManifestUrl $Destination = [IO.Path]::GetFullPath($Destination) if (Test-Path -LiteralPath $Destination) { throw 'Destination already exists. Use the in-app update command, or choose a new destination.' } $parent = [IO.Directory]::GetParent($Destination).FullName New-Item -ItemType Directory -Path $parent -Force | Out-Null $work = Join-Path $parent ('.laptop-setup-download-' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $work | Out-Null try { $manifestPath = Join-Path $work 'manifest.json' $release = $null if ($ManifestUrl.Host -eq 'api.github.com' -and $ManifestUrl.AbsolutePath -match '^/repos/[^/]+/[^/]+/releases/latest/?$') { $releasePath = Join-Path $work 'release.json' Save-BoundedDownload $ManifestUrl $releasePath 1MB $ManifestUrl $AccessToken 'application/vnd.github+json' $release = Get-Content -LiteralPath $releasePath -Raw | ConvertFrom-Json $assetUrl = Resolve-ReleaseAssetUrl $release $ManifestUrl 'latest.json' '' Save-BoundedDownload $assetUrl $manifestPath 1MB $ManifestUrl $AccessToken 'application/octet-stream' } else { Save-BoundedDownload $ManifestUrl $manifestPath 1MB $ManifestUrl $AccessToken } $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json if ($manifest.version -isnot [string] -or $manifest.version -notmatch '^\d+\.\d+\.\d+$' -or $manifest.packageUrl -isnot [string] -or $manifest.sha256 -isnot [string] -or $manifest.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Invalid release manifest.' } $zipPath = Join-Path $work 'package.zip' $packageUrl = [uri]$manifest.packageUrl if ($release) { $packageUrl = Resolve-ReleaseAssetUrl $release $ManifestUrl '' $manifest.packageUrl } Save-BoundedDownload $packageUrl $zipPath 1GB $ManifestUrl $AccessToken 'application/octet-stream' $stage = Join-Path $work 'app' Expand-VerifiedAppPackage $zipPath $manifest.sha256 $stage $actual = [Reflection.AssemblyName]::GetAssemblyName((Join-Path $stage 'LaptopSetupApp.dll')).Version $expected = [version]$manifest.version if ($actual.Major -ne $expected.Major -or $actual.Minor -ne $expected.Minor -or $actual.Build -ne $expected.Build) { throw 'Package assembly version does not match the manifest.' } [IO.Directory]::Move($stage, $Destination) Write-Host "Installed to $Destination" if (-not $NoLaunch) { Start-Process -FilePath (Join-Path $Destination 'LaptopSetupApp.exe') -WorkingDirectory $Destination -Verb RunAs } } finally { # This freshly generated child path is the only recursive cleanup target. if ([IO.Path]::GetFullPath($work).StartsWith($parent.TrimEnd('\') + '\.laptop-setup-download-', [StringComparison]::OrdinalIgnoreCase)) { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } } } -ManifestUrl 'https://laptopsetup.xenialevents.app/latest.json'