From 62e893349bc4d58370671289f9e4cb9fb6a5d65d Mon Sep 17 00:00:00 2001 From: Edgar Kalinovski Date: Mon, 27 Oct 2025 17:18:46 +0200 Subject: [PATCH] fix: tolerate proxy-injected HTTP responses in aka.ms redirect handling The dotnet install scripts previously assumed every non-final HTTP status in the aka.ms redirect chain must be 301. Some proxies inject additional HTTP response blocks such as "200 Connection Established", which caused valid redirect chains to be treated as broken. This change: - In Bash: find the last terminal HTTP status (2xx/4xx/5xx), consider only codes before that as the redirect chain, and trim trailing 1xx/2xx entries from it (proxy markers). Then assert all remaining redirect codes are 301. - In PowerShell: apply the equivalent logic inside Get-AkaMSDownloadLink. Includes tests/harnesses for local verification and comments explaining the reason for trimming proxy-injected statuses. Fixes: related discussion in actions/setup-dotnet#650 --- src/dotnet-install.ps1 | 106 ++++++++++++++++++++++++++++++++++------- src/dotnet-install.sh | 47 ++++++++++++++---- 2 files changed, 127 insertions(+), 26 deletions(-) diff --git a/src/dotnet-install.ps1 b/src/dotnet-install.ps1 index 57cb6cfc38..8acce6da65 100644 --- a/src/dotnet-install.ps1 +++ b/src/dotnet-install.ps1 @@ -1013,33 +1013,45 @@ function Get-AkaMSDownloadLink([string]$Channel, [string]$Quality, [bool]$Intern } $akaMsLink += "/$Product-win-$Architecture.zip" Say-Verbose "Constructed aka.ms link: '$akaMsLink'." + + # Collect observed status codes and the last Location header observed + $statusCodes = @() $akaMsDownloadLink = $null + $finalResponse = $null - for ($maxRedirections = 9; $maxRedirections -ge 0; $maxRedirections--) { - #get HTTP response - #do not pass credentials as a part of the $akaMsLink and do not apply credentials in the GetHTTPResponse function - #otherwise the redirect link would have credentials as well - #it would result in applying credentials twice to the resulting link and thus breaking it, and in echoing credentials to the output as a part of redirect link + for ($attempt = 0; $attempt -le 9; $attempt++) { + # get HTTP response header-only; do not auto-follow redirects; do not apply feed credentials $Response = GetHTTPResponse -Uri $akaMsLink -HeaderOnly $true -DisableRedirect $true -DisableFeedCredential $true Say-Verbose "Received response:`n$Response" - if ([string]::IsNullOrEmpty($Response)) { + if ($null -eq $Response) { Say-Verbose "The link '$akaMsLink' is not valid: failed to get redirect location. The resource is not available." return $null } - #if HTTP code is 301 (Moved Permanently), the redirect link exists - if ($Response.StatusCode -eq 301) { + $code = [int]$Response.StatusCode + $statusCodes += $code + + if ($code -eq 301) { + # Capture Location header and follow it try { - $akaMsDownloadLink = $Response.Headers.GetValues("Location")[0] + $locValues = $null + try { + $locValues = $Response.Headers.GetValues("Location") + } + catch { + # No Location found + $locValues = $null + } - if ([string]::IsNullOrEmpty($akaMsDownloadLink)) { + if ($null -eq $locValues -or $locValues.Count -eq 0 -or [string]::IsNullOrEmpty($locValues[0])) { Say-Verbose "The link '$akaMsLink' is not valid: server returned 301 (Moved Permanently), but the headers do not contain the redirect location." return $null } + $akaMsDownloadLink = $locValues[0] Say-Verbose "The redirect location retrieved: '$akaMsDownloadLink'." - # This may yet be a link to another redirection. Attempt to retrieve the page again. + # follow it for the next iteration (it may redirect again) $akaMsLink = $akaMsDownloadLink continue } @@ -1048,18 +1060,78 @@ function Get-AkaMSDownloadLink([string]$Channel, [string]$Quality, [bool]$Intern return $null } } - elseif ((($Response.StatusCode -lt 300) -or ($Response.StatusCode -ge 400)) -and (-not [string]::IsNullOrEmpty($akaMsDownloadLink))) { - # Redirections have ended. - return $akaMsDownloadLink + + # Non-301 response observed — treat it as terminal for analysis + $finalResponse = $Response + break + } + + if ($statusCodes.Count -eq 0) { + Say-Verbose "The link '$akaMsLink' is not valid: no HTTP responses were observed." + return $null + } + + # Find index of last terminal status (2xx, 4xx, 5xx) + $lastTerminalIdx = -1 + for ($i = $statusCodes.Count - 1; $i -ge 0; $i--) { + $s = $statusCodes[$i] + if ((($s -ge 200) -and ($s -le 299)) -or (($s -ge 400) -and ($s -le 599))) { + $lastTerminalIdx = $i + break + } + } + + # Build redirect candidate list: codes before last terminal (or the whole list if no terminal found) + if ($lastTerminalIdx -ge 0) { + if ($lastTerminalIdx -gt 0) { + $redirectCandidates = $statusCodes[0..($lastTerminalIdx - 1)] + } + else { + $redirectCandidates = @() } - Say-Verbose "The link '$akaMsLink' is not valid: failed to retrieve the redirection location." + # Trim trailing 1xx/2xx entries from redirectCandidates (likely proxy "Connection Established" markers) + while ($redirectCandidates.Count -gt 0) { + $last = $redirectCandidates[-1] + if (($last -ge 100) -and ($last -le 299)) { + if ($redirectCandidates.Count -eq 1) { + $redirectCandidates = @() + } + else { + $redirectCandidates = $redirectCandidates[0..($redirectCandidates.Count - 2)] + } + } + else { + break + } + } + } + else { + # No terminal code found — conservative fallback: require all codes to be 301 + $redirectCandidates = $statusCodes + } + + # Any redirect candidate that is not 301 indicates a broken redirect chain. + $brokenRedirects = $redirectCandidates | Where-Object { $_ -ne 301 } + + if ($brokenRedirects.Count -gt 0) { + Say-Verbose "The aka.ms link '$akaMsLink' is not valid: received HTTP code(s): $(( $brokenRedirects -join ',' ))" return $null } - Say-Verbose "Aka.ms links have redirected more than the maximum allowed redirections. This may be caused by a cyclic redirection of aka.ms links." - return $null + # Success: return the last captured Location (if any) + if (-not [string]::IsNullOrEmpty($akaMsDownloadLink)) { + Say-Verbose "Final redirect location (aka.ms): '$akaMsDownloadLink'." + return $akaMsDownloadLink + } + + # If we had a final non-301 response and a previously captured Location, return it; otherwise fail + if ($finalResponse -ne $null -and -not [string]::IsNullOrEmpty($akaMsDownloadLink)) { + return $akaMsDownloadLink + } + Say-Verbose "The link '$akaMsLink' is not valid: failed to retrieve the redirection location." + return $null } function Get-AkaMsLink-And-Version([string] $NormalizedChannel, [string] $NormalizedQuality, [bool] $Internal, [string] $ProductName, [string] $Architecture) { diff --git a/src/dotnet-install.sh b/src/dotnet-install.sh index 0e195282e4..955bf93724 100644 --- a/src/dotnet-install.sh +++ b/src/dotnet-install.sh @@ -1310,17 +1310,46 @@ get_download_link_from_aka_ms() { response="$(get_http_header $aka_ms_link $disable_feed_credential)" say_verbose "Received response: $response" - # Get results of all the redirects. - http_codes=$( echo "$response" | awk '$1 ~ /^HTTP/ {print $2}' ) - # They all need to be 301, otherwise some links are broken (except for the last, which is not a redirect but 200 or 404). - broken_redirects=$( echo "$http_codes" | sed '$d' | grep -v '301' ) - # The response may end without final code 2xx/4xx/5xx somehow, e.g. network restrictions on www.bing.com causes redirecting to bing.com fails with connection refused. - # In this case it should not exclude the last. - last_http_code=$( echo "$http_codes" | tail -n 1 ) - if ! [[ $last_http_code =~ ^(2|4|5)[0-9][0-9]$ ]]; then - broken_redirects=$( echo "$http_codes" | grep -v '301' ) + # Get results of all the HTTP status codes in the response (one per line). + # strip CRs before parsing to avoid \r interfering with regex matches + local http_codes + http_codes=$( printf '%s' "$response" | tr -d '\r' | awk '$1 ~ /^HTTP/ {print $2}' ) + + # Find the index (line number) of the last terminal HTTP status code (2xx, 4xx, 5xx). + local last_terminal_idx + last_terminal_idx=$( printf '%s' "$http_codes" | awk '/^(2|4|5)[0-9][0-9]$/{idx=NR} END{ if(idx) print idx }' ) + + local redirect_candidates + if [[ -n "$last_terminal_idx" ]]; then + if [[ "$last_terminal_idx" -gt 1 ]]; then + # Candidate redirect codes are those that appear before the last terminal code. + redirect_candidates=$( printf '%s' "$http_codes" | awk -v n="$last_terminal_idx" 'NR < n {print}' ) + else + redirect_candidates="" + fi + + # Trim trailing 1xx/2xx entries from redirect_candidates that are likely proxy markers. + redirect_candidates=$( printf '%s' "$redirect_candidates" | awk '{ + lines[NR] = $0 + } + END { + last = NR + while (last > 0 && lines[last] ~ /^(1|2)[0-9][0-9]$/) last-- + for (i = 1; i <= last; i++) print lines[i] + }' ) + + else + # No terminal code found — fall back to conservative behavior. + redirect_candidates="$http_codes" fi + # Any redirect candidate that is not '301' indicates a broken redirect chain. + local broken_redirects + broken_redirects=$( printf '%s' "$redirect_candidates" | grep -v '^301$' || true ) + + # Keep last_http_code for diagnostics + last_http_code=$( printf '%s' "$http_codes" | tail -n 1 ) + # All HTTP codes are 301 (Moved Permanently), the redirect link exists. if [[ -z "$broken_redirects" ]]; then aka_ms_download_link=$( echo "$response" | awk '$1 ~ /^Location/{print $2}' | tail -1 | tr -d '\r')