Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 30 additions & 14 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 '..') {
Expand All @@ -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" }
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Comment thread
BartoszBlizniak marked this conversation as resolved.
else
die "secure downloads require curl or GNU wget with --https-only"
fi
Expand Down
57 changes: 57 additions & 0 deletions tests/Install.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion tests/helpers/Fixture.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @'
Expand All @@ -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

Expand All @@ -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' }
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions tests/helpers/fixture.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions tests/install.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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" {
Expand Down
Loading