diff --git a/README.md b/README.md index 7b00ce7..2e3d03f 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,9 @@ The installers apply the following safeguards: The shell installer uses `curl` or `wget`; the PowerShell installer uses the .NET web request stack. Standard proxy configuration and operating-system -certificate trust stores apply. +certificate trust stores apply. All installer download requests send a +`cloudsmith-cli-install-script` user agent that includes the operating system +and architecture. To report a potential vulnerability, follow the [security policy](SECURITY.md) instead of opening a public issue. diff --git a/install.ps1 b/install.ps1 index 92b540a..3014d99 100644 --- a/install.ps1 +++ b/install.ps1 @@ -32,6 +32,7 @@ $ProgressPreference = "SilentlyContinue" # construction entirely. $DownloadBaseUrl = "https://dl.cloudsmith.io/public" $ManifestNamePrefix = "cloudsmith-cli-manifest" +$script:InstallerUserAgent = "cloudsmith-cli-install-script" $script:WorkDir = $null $script:LockStream = $null @@ -87,6 +88,17 @@ function Resolve-DownloadRedirect { return $nextUri.AbsoluteUri } +function Get-InstallerWebRequest { + param([Parameter(Mandatory = $true)][string]$Uri) + + $request = [System.Net.HttpWebRequest][System.Net.WebRequest]::Create($Uri) + $request.AllowAutoRedirect = $false + $request.UserAgent = $script:InstallerUserAgent + $request.Timeout = 300000 + $request.ReadWriteTimeout = 300000 + return $request +} + function Invoke-HttpsDownload { param( [Parameter(Mandatory = $true)][string]$Uri, @@ -99,10 +111,7 @@ function Invoke-HttpsDownload { while ($true) { Test-DownloadUriAllowed -Uri $currentUri - $request = [System.Net.HttpWebRequest][System.Net.WebRequest]::Create($currentUri) - $request.AllowAutoRedirect = $false - $request.Timeout = 300000 - $request.ReadWriteTimeout = 300000 + $request = Get-InstallerWebRequest -Uri $currentUri $response = $null try { @@ -173,7 +182,8 @@ function Invoke-Download { # download to a temp path and copy to the destination literally. $tempFile = [System.IO.Path]::GetTempFileName() try { - Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $tempFile -TimeoutSec 300 | Out-Null + Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $tempFile ` + -TimeoutSec 300 -UserAgent $script:InstallerUserAgent | Out-Null [System.IO.File]::Copy($tempFile, $Destination, $true) } finally { @@ -208,6 +218,19 @@ function Get-KeyValueFile { return $values } +function Get-OSArchitecture { + try { + return [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } + catch { + $architecture = $env:PROCESSOR_ARCHITEW6432 + if (-not $architecture) { + $architecture = $env:PROCESSOR_ARCHITECTURE + } + return $architecture + } +} + function Get-DetectedTarget { if ($Target) { if ($Target -notmatch '^[A-Za-z0-9._-]+$' -or $Target -eq '.' -or $Target -eq '..') { @@ -220,15 +243,7 @@ function Get-DetectedTarget { throw "install.ps1: this installer is intended for Windows; use install.sh on Unix hosts" } - try { - $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - } - catch { - $architecture = $env:PROCESSOR_ARCHITEW6432 - if (-not $architecture) { - $architecture = $env:PROCESSOR_ARCHITECTURE - } - } + $architecture = Get-OSArchitecture switch -Regex ($architecture) { '^(X64|AMD64)$' { return "windows-x86_64" } @@ -339,6 +354,7 @@ try { $resolvedTarget = Get-DetectedTarget Write-InstallerLog "detected target $resolvedTarget" + $script:InstallerUserAgent = "cloudsmith-cli-install-script (Windows; $(Get-OSArchitecture))" $temporaryRoot = Join-Path $InstallRoot ".tmp" [void][System.IO.Directory]::CreateDirectory($temporaryRoot) diff --git a/install.sh b/install.sh index f0d6a4f..4cc0b1a 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,7 @@ set -eu PROGRAM="install.sh" +INSTALLER_USER_AGENT="cloudsmith-cli-install-script ($(uname -s 2>/dev/null || echo unknown); $(uname -m 2>/dev/null || echo unknown))" # Where releases are downloaded from. Flags and CLOUDSMITH_CLI_* environment # variables override the defaults; --manifest-url bypasses URL construction. @@ -102,19 +103,21 @@ http_download() { if command -v curl >/dev/null 2>&1; then if [ "$ALLOW_INSECURE_URLS" = true ]; then curl --fail --silent --show-error --location \ + --user-agent "$INSTALLER_USER_AGENT" \ --retry 3 --retry-delay 1 --connect-timeout 20 --max-time 300 \ --output "$output" "$url" else curl --fail --silent --show-error --location \ --proto '=https' --proto-redir '=https' \ + --user-agent "$INSTALLER_USER_AGENT" \ --retry 3 --retry-delay 1 --connect-timeout 20 --max-time 300 \ --output "$output" "$url" fi elif command -v wget >/dev/null 2>&1; then if [ "$ALLOW_INSECURE_URLS" = true ]; then - wget -q -T 300 -O "$output" "$url" + wget -q -T 300 -U "$INSTALLER_USER_AGENT" -O "$output" "$url" elif wget --help 2>&1 | grep -- '--https-only' >/dev/null 2>&1; then - wget -q -T 300 --https-only -O "$output" "$url" + wget -q -T 300 --https-only -U "$INSTALLER_USER_AGENT" -O "$output" "$url" else die "secure downloads require curl or GNU wget with --https-only" fi diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index d689747..e268bbe 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -7,6 +7,45 @@ # $IsWindows doesn't exist in Windows PowerShell 5.1, which is always Windows. $script:RunningOnWindows = if ($PSVersionTable.PSVersion.Major -ge 6) { $IsWindows } else { $true } +Describe 'Cloudsmith CLI installer request metadata' { + + BeforeAll { + $installScriptPath = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'install.ps1')).ProviderPath + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $installScriptPath, + [ref]$tokens, + [ref]$errors + ) + if ($errors.Count -gt 0) { + throw 'install.ps1 has parse errors' + } + + $requestFunction = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Get-InstallerWebRequest' + }, $true) + if (-not $requestFunction) { + throw 'unable to find Get-InstallerWebRequest' + } + + $script:InstallerUserAgent = 'cloudsmith-cli-install-script' + . ([scriptblock]::Create($requestFunction.Extent.Text)) + } + + It 'sets the stable user agent on secure web requests' { + $request = Get-InstallerWebRequest -Uri 'https://downloads.example.test/archive.zip' + try { + $request.UserAgent | Should -Be 'cloudsmith-cli-install-script' + } + finally { + $request.Abort() + } + } +} + Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $script:RunningOnWindows) { BeforeAll { @@ -162,6 +201,24 @@ Describe 'Cloudsmith CLI installer (install.ps1)' -Tag 'Windows' -Skip:(-not $sc $LASTEXITCODE | Should -Be 0 } + It 'identifies PowerShell installer requests with a custom user agent' { + $installRoot = Join-Path $TestDrive ("root-" + [Guid]::NewGuid().ToString('N')) + $fixture = New-CloudsmithFixture -Directory $script:FixtureDir -Version '1.2.3' -Target $script:DefaultTarget -BaseUrl $script:Server.Url + + $result = Invoke-Installer -HostExe $currentHostExe -ScriptArguments @( + '-Version', '1.2.3', + '-InstallRoot', $installRoot, + '-Target', $script:DefaultTarget, + '-ManifestUrl', $fixture.ManifestUrl + ) + + $result.ExitCode | Should -Be 0 + $userAgentRequests = @(Get-Content -LiteralPath $script:Server.LogPath | Where-Object { + $_ -like 'User-Agent: cloudsmith-cli-install-script (*' + }) + $userAgentRequests.Count | Should -Be 2 + } + It "resolves 'latest' to the version reported in the manifest" { $installRoot = Join-Path $TestDrive ("root-" + [Guid]::NewGuid().ToString('N')) $fixture = New-CloudsmithFixture -Directory $script:FixtureDir -Version '2.4.6' -Target $script:DefaultTarget -BaseUrl $script:Server.Url diff --git a/tests/helpers/Fixture.psm1 b/tests/helpers/Fixture.psm1 index 3f4c9f7..7a3e342 100644 --- a/tests/helpers/Fixture.psm1 +++ b/tests/helpers/Fixture.psm1 @@ -274,6 +274,7 @@ function Start-FixtureServer { $id = [Guid]::NewGuid().ToString('N') $portFile = Join-Path ([System.IO.Path]::GetTempPath()) "fixture-port-$id.txt" + $logFile = Join-Path ([System.IO.Path]::GetTempPath()) "fixture-requests-$id.log" $scriptPath = Join-Path ([System.IO.Path]::GetTempPath()) "fixture-server-$id.py" $serverScript = @' @@ -283,9 +284,15 @@ import socketserver import sys os.chdir(sys.argv[1]) +log_file = sys.argv[3] class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + with open(log_file, "a") as handle: + handle.write("User-Agent: {}\n".format(self.headers.get("User-Agent", ""))) + super().do_GET() + def log_message(self, *args): pass @@ -297,10 +304,16 @@ httpd.serve_forever() '@ $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($scriptPath, $serverScript, $utf8NoBom) + [System.IO.File]::WriteAllText($logFile, '', $utf8NoBom) $startArgs = @{ FilePath = $python.Source - ArgumentList = @('"' + $scriptPath + '"', '"' + $Directory + '"', '"' + $portFile + '"') + ArgumentList = @( + '"' + $scriptPath + '"', + '"' + $Directory + '"', + '"' + $portFile + '"', + '"' + $logFile + '"' + ) PassThru = $true } if ($env:OS -eq 'Windows_NT') { $startArgs['WindowStyle'] = 'Hidden' } @@ -324,6 +337,7 @@ httpd.serve_forever() Url = "http://127.0.0.1:$port" Directory = $Directory Port = $port + LogPath = $logFile } Add-Member -InputObject $server -MemberType NoteProperty -Name Process -Value $process Add-Member -InputObject $server -MemberType NoteProperty -Name PortFile -Value $portFile @@ -335,6 +349,7 @@ httpd.serve_forever() try { Stop-Process -Id $this.Process.Id -Force -ErrorAction SilentlyContinue } catch { } try { Remove-Item -LiteralPath $this.PortFile -Force -ErrorAction SilentlyContinue } catch { } try { Remove-Item -LiteralPath $this.ScriptPath -Force -ErrorAction SilentlyContinue } catch { } + try { Remove-Item -LiteralPath $this.LogPath -Force -ErrorAction SilentlyContinue } catch { } } return $server diff --git a/tests/helpers/fixture.bash b/tests/helpers/fixture.bash index c065818..6e89fb5 100644 --- a/tests/helpers/fixture.bash +++ b/tests/helpers/fixture.bash @@ -47,6 +47,11 @@ os.chdir(served_dir) class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + with open(log_file, "a") as handle: + handle.write(f"User-Agent: {self.headers.get('User-Agent', '')}\n") + super().do_GET() + def log_message(self, fmt, *args): with open(log_file, "a") as handle: handle.write((fmt % args) + "\n") diff --git a/tests/install.bats b/tests/install.bats index 0e22058..41c39ba 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -54,6 +54,7 @@ while [ "$#" -gt 0 ]; do case "$1" in -O) output="$2"; shift 2 ;; -T) shift 2 ;; + -U) shift 2 ;; -q) shift ;; --https-only) printf '%s\n' "unexpected --https-only" >&2; exit 2 ;; -*) shift ;; @@ -95,6 +96,22 @@ executable=$bin_dir/cloudsmith" [[ "$output" == *"CLI Package Version: 1.19.0"* ]] } +@test "curl identifies installer requests with a custom user agent" { + command -v curl >/dev/null 2>&1 || skip "curl is required for this test" + build_fixture "$FIXTURE_DIR" "1.19.0" "$TEST_TARGET" + + run --separate-stderr "$INSTALL_SH" \ + --version 1.19.0 \ + --target "$TEST_TARGET" \ + --install-root "$INSTALL_ROOT" \ + --manifest-url "$FIXTURE_URL/manifest.txt" + + [ "$status" -eq 0 ] + run grep -c '^User-Agent: cloudsmith-cli-install-script (' "$FIXTURE_SERVER_LOG" + [ "$status" -eq 0 ] + [ "$output" -eq 2 ] +} + @test "--help prints usage with HOME and XDG_DATA_HOME unset" { run --separate-stderr env -u HOME -u XDG_DATA_HOME "$INSTALL_SH" --help @@ -516,6 +533,9 @@ executable=$bin_dir/cloudsmith" [ "$status" -eq 0 ] [ -x "$INSTALL_ROOT/1.19.0/$TEST_TARGET/cloudsmith/cloudsmith" ] [[ "$(cat "$WGET_LOG")" != *"--https-only"* ]] + run grep -c -- '-U cloudsmith-cli-install-script' "$WGET_LOG" + [ "$status" -eq 0 ] + [ "$output" -eq 2 ] } @test "http:// manifest URL without CLOUDSMITH_CLI_ALLOW_INSECURE_URLS fails" {