diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 4c74a50..18042dc 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -275,11 +275,21 @@ function Resolve-Tag { # Follow the /releases/latest redirect to find the tag, same trick # install.sh uses. -MaximumRedirection 0 makes Invoke-WebRequest # surface the Location header instead of following. + # + # This is the one fetch that stays a plain Invoke-WebRequest, not a + # Save-BoundedFile: it is a header-only redirect probe (-MaximumRedirection 0 + # reads the Location header, never a body to disk), so the size ceiling that + # backend#2544 added to the artifact fetches has nothing to bound here. The + # STALL half still applies, though, so pin -TimeoutSec 60 to match the rest of + # the script rather than inherit the ~100 s platform default (LukasWodka, #588). + # On timeout the WebException is caught below, $resp is $null, and Resolve-Tag + # falls through to a clean Fail — never a hang. try { $resp = Invoke-WebRequest ` -Uri "https://github.com/$script:GitHubRepo/releases/latest" ` -MaximumRedirection 0 ` -UseBasicParsing ` + -TimeoutSec 60 ` -ErrorAction SilentlyContinue } catch { # PowerShell treats 3xx as an error when MaximumRedirection=0. @@ -311,11 +321,30 @@ $baseUrl = "https://github.com/$GitHubRepo/releases/download/$tag" $tmpDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "tracebloc-install-$tag-$([guid]::NewGuid())") -Force try { - Write-Host "Downloading binary..." - Invoke-WebRequest -Uri "$baseUrl/$binaryFile" -OutFile (Join-Path $tmpDir $binaryFile) -UseBasicParsing + # Bounded, exactly like the cosign bootstrap above (backend#2544 finishes + # what backend#2199 started). These artifacts are SHA256- and cosign-verified + # AFTER download, so a corrupted or oversized body is rejected before use — + # but an unbounded one can still hang the install or exhaust disk BEFORE that + # verification runs, the same DoS the bootstrap fetch closed. So every fetch + # goes through Save-BoundedFile (size ceiling + timeout) rather than a raw + # Invoke-WebRequest. Caps are sized against the real published assets with + # headroom for growth; REVISIT the binary cap as the CLI grows: + # binary ~50 MB today (windows-amd64 is 49.9 MB at v0.10.x) → 200 MB + # SHA256SUMS <1 KB → a tight 1 MB / 60 s, mirroring cosign_checksums.txt + # Wrapped so a bounded-fetch throw — cap tripped, timeout, 404, oversized + # body — surfaces as Fail's clean one-line error, not the raw PowerShell + # error record + stack trace that an uncaught throw under `Stop` produces. + # These two are mandatory (unlike cosign, there is no fallback), so the + # right response is a clean hard failure, not the bootstrap's warn-and-continue. + try { + Write-Host "Downloading binary..." + Save-BoundedFile -Uri "$baseUrl/$binaryFile" -OutFile (Join-Path $tmpDir $binaryFile) -MaxBytes 200MB - Write-Host "Downloading SHA256SUMS..." - Invoke-WebRequest -Uri "$baseUrl/SHA256SUMS" -OutFile (Join-Path $tmpDir 'SHA256SUMS') -UseBasicParsing + Write-Host "Downloading SHA256SUMS..." + Save-BoundedFile -Uri "$baseUrl/SHA256SUMS" -OutFile (Join-Path $tmpDir 'SHA256SUMS') -MaxBytes 1MB -TimeoutSec 60 + } catch { + Fail "couldn't download the release artifacts for ${tag}: $($_.Exception.Message)" + } # ------------------------------------------------------------- # Verify SHA256. @@ -400,8 +429,13 @@ try { # process, whose non-zero $LASTEXITCODE cannot be caught anyway. $sigDownloaded = $false try { - Invoke-WebRequest -Uri "$baseUrl/$binaryFile.sig" -OutFile (Join-Path $tmpDir "$binaryFile.sig") -UseBasicParsing - Invoke-WebRequest -Uri "$baseUrl/$binaryFile.cert" -OutFile (Join-Path $tmpDir "$binaryFile.cert") -UseBasicParsing + # Bounded too (backend#2544). Both are tiny — .sig ~96 B, .cert ~3.2 KB + # — so a 1 MB / 60 s profile bounds a runaway with vast headroom. A + # Save-BoundedFile throw (cap tripped, timeout, 404, oversized body) + # lands in the same catch that already handles a missing .sig/.cert: + # fail closed unless TRACEBLOC_ALLOW_UNVERIFIED=1. + Save-BoundedFile -Uri "$baseUrl/$binaryFile.sig" -OutFile (Join-Path $tmpDir "$binaryFile.sig") -MaxBytes 1MB -TimeoutSec 60 + Save-BoundedFile -Uri "$baseUrl/$binaryFile.cert" -OutFile (Join-Path $tmpDir "$binaryFile.cert") -MaxBytes 1MB -TimeoutSec 60 $sigDownloaded = $true } catch { if (-not $AllowUnverified) { diff --git a/scripts/tests/install-ps1-functions.tests.ps1 b/scripts/tests/install-ps1-functions.tests.ps1 index c1a8f83..9b491df 100644 --- a/scripts/tests/install-ps1-functions.tests.ps1 +++ b/scripts/tests/install-ps1-functions.tests.ps1 @@ -328,6 +328,46 @@ if ($backslashDollar.Count -eq 0) { $backslashDollar | ForEach-Object { bad "bash-style \$ escape in: $($_.Extent.Text)" } } +# ── 8. every artifact fetched to disk is size-bounded (backend#2544) ──────── +# backend#2199 bounded the cosign bootstrap. #2544 bounds the four artifacts +# fetched AFTER it — the CLI binary, SHA256SUMS, and the .sig/.cert — which were +# still raw Invoke-WebRequest: verified after download, but an unbounded body can +# hang the install or exhaust disk BEFORE that check runs. +# +# Save-BoundedFile's byte ceiling is behaviourally driven above (section 2b); the +# NEW surface here is the call sites, which are inline in the main script body +# and can't be extracted and run without the network. So they're asserted +# structurally against the AST — robust to reformatting, unlike a grep: +# (a) the anti-pattern is gone: nothing writes a body to a file via +# Invoke-WebRequest -OutFile anymore. This is the sharp invariant — it +# fails the moment someone adds the next raw fetch. Resolve-Tag's redirect +# probe stays legal: it reads a Location header, never passes -OutFile. +# (b) the mechanism is intact: every Save-BoundedFile call carries an explicit +# -MaxBytes, so a fetch routed through the helper can't skip the ceiling. +function Test-HasParam($cmdAst, [string]$name) { + return @($cmdAst.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.CommandParameterAst] -and + $_.ParameterName -eq $name + }).Count -gt 0 +} +$iwrToFile = @($ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.CommandAst] -and + $n.GetCommandName() -eq 'Invoke-WebRequest' +}, $true) | Where-Object { Test-HasParam $_ 'OutFile' }) +is 'no artifact is fetched to disk with a raw Invoke-WebRequest -OutFile' $iwrToFile.Count 0 + +$sbfCalls = @($ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.CommandAst] -and + $n.GetCommandName() -eq 'Save-BoundedFile' +}, $true)) +# Non-vacuity: the -MaxBytes assertion below is only meaningful if call sites +# exist. Two cosign-bootstrap fetches + four post-cosign artifacts = six. +is 'Save-BoundedFile is used at every artifact fetch (>= 6 call sites)' ($sbfCalls.Count -ge 6) $true +$sbfNoCap = @($sbfCalls | Where-Object { -not (Test-HasParam $_ 'MaxBytes') }) +is 'every Save-BoundedFile call site carries an explicit -MaxBytes ceiling' $sbfNoCap.Count 0 + Write-Host '' Write-Host "install-ps1-functions: $script:Pass passed, $script:Fail failed" if ($script:Fail -gt 0) { exit 1 } diff --git a/scripts/tests/install-ps1-verify.sh b/scripts/tests/install-ps1-verify.sh index c1767af..671c99c 100755 --- a/scripts/tests/install-ps1-verify.sh +++ b/scripts/tests/install-ps1-verify.sh @@ -108,6 +108,32 @@ else bad 'a cosign bootstrap fetch is missing its size cap' fi +# ── 5c. the post-cosign artifact fetches are size-bounded too (backend#2544) ─ +# #2199/5b bounded only the cosign bootstrap — the pre-auth trust root. The four +# artifacts fetched AFTER it (binary, SHA256SUMS, .sig, .cert) were still raw +# Invoke-WebRequest: they are SHA256- and cosign-verified after download, but an +# unbounded body can hang the install or exhaust disk BEFORE that check runs — +# the same DoS. The sharp invariant that keeps them bounded, and catches the next +# raw fetch someone adds: NOTHING is written to a file with Invoke-WebRequest +# -OutFile anymore. (Resolve-Tag's redirect probe has no -OutFile — it reads a +# Location header, not a body — so it is correctly not caught here.) +if grep -Eq 'Invoke-WebRequest.*-OutFile' "$INSTALLER"; then + bad 'an artifact is still fetched to disk with a raw Invoke-WebRequest -OutFile' +else + ok 'no artifact is fetched to disk with a raw Invoke-WebRequest -OutFile' +fi +# …and each of the four goes through the bounded helper with an explicit cap. +# The closing quote in each pattern pins the exact asset — "$baseUrl/$binaryFile" +# must not also match the "$binaryFile.sig" / ".cert" lines. +if grep -Eq 'Save-BoundedFile.*"\$baseUrl/\$binaryFile".*-MaxBytes' "$INSTALLER" \ + && grep -Eq 'Save-BoundedFile.*"\$baseUrl/SHA256SUMS".*-MaxBytes' "$INSTALLER" \ + && grep -Eq 'Save-BoundedFile.*"\$baseUrl/\$binaryFile\.sig".*-MaxBytes' "$INSTALLER" \ + && grep -Eq 'Save-BoundedFile.*"\$baseUrl/\$binaryFile\.cert".*-MaxBytes' "$INSTALLER"; then + ok 'binary, SHA256SUMS, .sig and .cert are all size-capped via the bounded helper' +else + bad 'a post-cosign artifact fetch is missing its size cap' +fi + # ── 6. behavioural tier ───────────────────────────────────────────────────── # pwsh is preinstalled on GitHub-hosted ubuntu runners. If it is missing we # cannot tell whether the helpers behave, and "cannot tell" is a finding, not a