diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs index ea47b0cd91..c1e8ec9bd2 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs @@ -24,7 +24,7 @@ namespace Microsoft.PowerShell.Commands { /// /// Exception class for webcmdlets to enable returning HTTP error response - /// + /// public sealed class HttpResponseException : HttpRequestException { /// @@ -48,6 +48,22 @@ namespace Microsoft.PowerShell.Commands /// public abstract partial class WebRequestPSCmdlet : PSCmdlet { + + /// + /// gets or sets the PreserveAuthorizationOnRedirect property + /// + /// + /// This property overrides compatibility with web requests on Windows. + /// On FullCLR (WebRequest), authorization headers are stripped during redirect. + /// CoreCLR (HTTPClient) does not have this behavior so web requests that work on + /// PowerShell/FullCLR can fail with PowerShell/CoreCLR. To provide compatibility, + /// we'll detect requests with an Authorization header and automatically strip + /// the header when the first redirect occurs. This switch turns off this logic for + /// edge cases where the authorization header needs to be preserved across redirects. + /// + [Parameter] + public virtual SwitchParameter PreserveAuthorizationOnRedirect { get; set; } + #region Abstract Methods /// @@ -111,7 +127,9 @@ namespace Microsoft.PowerShell.Commands #region Virtual Methods - internal virtual HttpClient GetHttpClient() + // NOTE: Only pass true for handleRedirect if the original request has an authorization header + // and PreserveAuthorizationOnRedirect is NOT set. + internal virtual HttpClient GetHttpClient(bool handleRedirect) { // By default the HttpClientHandler will automatically decompress GZip and Deflate content HttpClientHandler handler = new HttpClientHandler(); @@ -150,7 +168,12 @@ namespace Microsoft.PowerShell.Commands handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; } - if (WebSession.MaximumRedirection > -1) + // This indicates GetResponse will handle redirects. + if (handleRedirect) + { + handler.AllowAutoRedirect = false; + } + else if (WebSession.MaximumRedirection > -1) { if (WebSession.MaximumRedirection == 0) { @@ -178,7 +201,7 @@ namespace Microsoft.PowerShell.Commands return httpClient; } - internal virtual HttpRequestMessage GetRequest(Uri uri) + internal virtual HttpRequestMessage GetRequest(Uri uri, bool stripAuthorization) { Uri requestUri = PrepareUri(uri); HttpMethod httpMethod = null; @@ -217,6 +240,13 @@ namespace Microsoft.PowerShell.Commands } else { + if (stripAuthorization + && + String.Equals(entry.Key, HttpKnownHeaderNames.Authorization.ToString(), StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } request.Headers.Add(entry.Key, entry.Value); } } @@ -367,13 +397,77 @@ namespace Microsoft.PowerShell.Commands } } - internal virtual HttpResponseMessage GetResponse(HttpClient client, HttpRequestMessage request) + // Returns true if the status code is one of the supported redirection codes. + static bool IsRedirectCode(HttpStatusCode code) + { + int intCode = (int) code; + return + ( + (intCode >= 300 && intCode < 304) + || + intCode == 307 + ); + } + + // Returns true if the status code is a redirection code and the action requires switching from POST to GET on redirection. + // NOTE: Some of these status codes map to the same underlying value but spelling them out for completeness. + static bool IsRedirectToGet(HttpStatusCode code) + { + return + ( + code == HttpStatusCode.Found + || + code == HttpStatusCode.Moved + || + code == HttpStatusCode.Redirect + || + code == HttpStatusCode.RedirectMethod + || + code == HttpStatusCode.TemporaryRedirect + || + code == HttpStatusCode.RedirectKeepVerb + || + code == HttpStatusCode.SeeOther + ); + } + + internal virtual HttpResponseMessage GetResponse(HttpClient client, HttpRequestMessage request, bool stripAuthorization) { if (client == null) { throw new ArgumentNullException("client"); } if (request == null) { throw new ArgumentNullException("request"); } _cancelToken = new CancellationTokenSource(); - return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); + HttpResponseMessage response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); + + if (stripAuthorization && IsRedirectCode(response.StatusCode)) + { + _cancelToken.Cancel(); + _cancelToken = null; + + // if explicit count was provided, reduce it for this redirection. + if (WebSession.MaximumRedirection > 0) + { + WebSession.MaximumRedirection--; + } + // For selected redirects that used POST, GET must be used with the + // redirected Location. + // Since GET is the default; POST only occurs when -Method POST is used. + if (Method == WebRequestMethod.Post && IsRedirectToGet(response.StatusCode)) + { + // See https://msdn.microsoft.com/en-us/library/system.net.httpstatuscode(v=vs.110).aspx + Method = WebRequestMethod.Get; + } + + // recreate the HttpClient with redirection enabled since the first call suppressed redirection + using (client = GetHttpClient(false)) + using (HttpRequestMessage redirectRequest = GetRequest(response.Headers.Location, stripAuthorization:true)) + { + FillRequestStream(redirectRequest); + _cancelToken = new CancellationTokenSource(); + response = client.SendAsync(redirectRequest, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); + } + } + return response; } internal virtual void UpdateSession(HttpResponseMessage response) @@ -396,7 +490,17 @@ namespace Microsoft.PowerShell.Commands ValidateParameters(); PrepareSession(); - using (HttpClient client = GetHttpClient()) + // if the request contains an authorization header and PreserveAuthorizationOnRedirect is not set, + // it needs to be stripped on the first redirect. + bool stripAuthorization = null != WebSession + && + null != WebSession.Headers + && + !PreserveAuthorizationOnRedirect.IsPresent + && + WebSession.Headers.ContainsKey(HttpKnownHeaderNames.Authorization.ToString()); + + using (HttpClient client = GetHttpClient(stripAuthorization)) { int followedRelLink = 0; Uri uri = Uri; @@ -410,7 +514,7 @@ namespace Microsoft.PowerShell.Commands WriteVerbose(linkVerboseMsg); } - using (HttpRequestMessage request = GetRequest(uri)) + using (HttpRequestMessage request = GetRequest(uri, stripAuthorization:false)) { FillRequestStream(request); try @@ -426,7 +530,7 @@ namespace Microsoft.PowerShell.Commands requestContentLength); WriteVerbose(reqVerboseMsg); - HttpResponseMessage response = GetResponse(client, request); + HttpResponseMessage response = GetResponse(client, request, stripAuthorization); string contentType = ContentHelper.GetContentType(response); string respVerboseMsg = string.Format(CultureInfo.CurrentCulture, diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index a2608335ad..d694b76241 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -143,6 +143,70 @@ function GetTestData return $body } +function ExecuteRedirectRequest +{ + param ( + [Parameter(Mandatory)] + [string] + $uri, + + [ValidateSet('Invoke-WebRequest', 'Invoke-RestMethod')] + [string] $Cmdlet = 'Invoke-WebRequest', + + [ValidateSet('POST', 'GET')] + [string] $Method = 'GET', + + [switch] $PreserveAuthorizationOnRedirect + ) + $result = [PSObject]@{Output = $null; Error = $null; Content = $null} + + try + { + $headers = @{"Authorization" = "test"} + if ($Cmdlet -eq 'Invoke-WebRequest') + { + $result.Output = Invoke-WebRequest -Uri $uri -TimeoutSec 5 -Headers $headers -PreserveAuthorizationOnRedirect:$PreserveAuthorizationOnRedirect.IsPresent -Method $Method + $result.Content = $result.Output.Content | ConvertFrom-Json + } + else + { + $result.Output = Invoke-RestMethod -Uri $uri -TimeoutSec 5 -Headers $headers -PreserveAuthorizationOnRedirect:$PreserveAuthorizationOnRedirect.IsPresent -Method $Method + # NOTE: $result.Output should already be a PSObject (Invoke-RestMethod converts the returned json automatically) + # so simply reference $result.Output + $result.Content = $result.Output + } + } + catch + { + $result.Error = $_ + } + + return $result +} + +<# + Defines the list of redirect codes to test as well as the + expected Method when the redirection is handled. + See https://msdn.microsoft.com/en-us/library/windows/apps/system.net.httpstatuscode(v=vs.105).aspx + for additonal details. +#> +$redirectTests = @( + @{redirectType = 'MultipleChoices'; redirectedMethod='POST'} + @{redirectType = 'Ambiguous'; redirectedMethod='POST'} # Synonym for MultipleChoices + + @{redirectType = 'Moved'; redirectedMethod='GET'} + @{redirectType = 'MovedPermanently'; redirectedMethod='GET'} # Synonym for Moved + + @{redirectType = 'Found'; redirectedMethod='GET'} + @{redirectType = 'Redirect'; redirectedMethod='GET'} # Synonym for Found + + @{redirectType = 'redirectMethod'; redirectedMethod='GET'} + @{redirectType = 'SeeOther'; redirectedMethod='GET'} # Synonym for RedirectMethod + + @{redirectType = 'TemporaryRedirect'; redirectedMethod='GET'} + @{redirectType = 'RedirectKeepVerb'; redirectedMethod='GET'} # Synonym for TemporaryRedirect +) + Describe "Invoke-WebRequest tests" -Tags "Feature" { BeforeAll { @@ -223,7 +287,7 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $jsonContent.headers.Host | Should Match "httpbin.org" $jsonContent.headers.'User-Agent' | Should Match "WindowsPowerShell" } - + It "Validate Invoke-WebRequest error for -MaximumRedirection" { $command = "Invoke-WebRequest -Uri 'http://httpbin.org/redirect/3' -MaximumRedirection 2 -TimeoutSec 5" @@ -231,7 +295,7 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $result = ExecuteWebCommand -command $command $result.Error.FullyQualifiedErrorId | Should Be "WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand" } - + It "Invoke-WebRequest supports request that returns page containing UTF-8 data." { $command = "Invoke-WebRequest -Uri http://httpbin.org/encoding/utf8 -TimeoutSec 5" @@ -545,6 +609,59 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $result.Output.RelationLink["last"] | Should BeExactly "http://localhost:8080/PowerShell?test=linkheader&maxlinks=3&linknumber=3" } + #region Redirect tests + + It "Validates Invoke-WebRequest with -PreserveAuthorizationOnRedirect preserves the authorization header on redirect" -TestCases $redirectTests { + param($redirectType, $redirectedMethod) + + $response = ExecuteRedirectRequest -Uri "http://localhost:8080/PowerShell?test=redirect&type=$redirectType" -PreserveAuthorizationOnRedirect + + $response.Error | Should BeNullOrEmpty + # ensure Authorization header has been preserved. + $response.Content.Headers -contains "Authorization" | Should Be $true + } + + + It "Validates Invoke-WebRequest preserves the authorization header on multiple redirects." -TestCases $redirectTests { + param($redirectType) + + $response = ExecuteRedirectRequest -Uri "http://localhost:8080/PowerShell?test=redirect&type=$redirectType&multiredirect=true" -PreserveAuthorizationOnRedirect + + $response.Error | Should BeNullOrEmpty + # ensure Authorization header was stripped + $response.Content.Headers -contains "Authorization" | Should Be $true + } + + It "Validates Invoke-WebRequest strips the authorization header on various redirects" -TestCases $redirectTests { + param($redirectType) + + $response = ExecuteRedirectRequest -Uri "http://localhost:8080/PowerShell?test=redirect&type=$redirectType" + + $response.Error | Should BeNullOrEmpty + # ensure user-agent is present (i.e., no false positives ) + $response.Content.Headers -contains "User-Agent" | Should Be $true + # ensure Authorization header has been removed. + $response.Content.Headers -contains "Authorization" | Should Be $false + } + + # NOTE: Only testing redirection of POST -> GET for unique underlying values of HttpStatusCode. + # Some names overlap in underlying value. + It "Validates Invoke-WebRequest strips the authorization header redirects and switches from POST to GET when it handles the redirect" -TestCases $redirectTests { + param($redirectType, $redirectedMethod) + + $response = ExecuteRedirectRequest -Uri "http://localhost:8080/PowerShell?test=redirect&type=$redirectType" -Method 'POST' + + $response.Error | Should BeNullOrEmpty + # ensure user-agent is present (i.e., no false positives ) + $response.Content.Headers -contains "User-Agent" | Should Be $true + # ensure Authorization header has been removed. + $response.Content.Headers -contains "Authorization" | Should Be $false + # ensure POST was changed to GET for selected redirections and remains as POST for others. + $response.Content.HttpMethod | Should Be $redirectedMethod + } + + #endregion Redirect tests + BeforeEach { if ($env:http_proxy) { $savedHttpProxy = $env:http_proxy @@ -955,6 +1072,58 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { $result.Output.output | Should BeExactly 1 } + #region Redirect tests + + It "Validates Invoke-RestMethod with -PreserveAuthorizationOnRedirect preserves the authorization header on redirect" -TestCases $redirectTests { + param($redirectType, $redirectedMethod) + + $response = ExecuteRedirectRequest -Cmdlet 'Invoke-RestMethod' -Uri "http://localhost:8081/PowerShell?test=redirect&type=$redirectType" -PreserveAuthorizationOnRedirect + + $response.Error | Should BeNullOrEmpty + # ensure Authorization header has been preserved. + $response.Content.Headers -contains "Authorization" | Should Be $true + } + + It "Validates Invoke-RestMethod preserves the authorization header on multiple redirects." -TestCases $redirectTests { + param($redirectType) + + $response = ExecuteRedirectRequest -Cmdlet 'Invoke-RestMethod' -Uri "http://localhost:8081/PowerShell?test=redirect&type=$redirectType&multiredirect=true" -PreserveAuthorizationOnRedirect + + $response.Error | Should BeNullOrEmpty + # ensure Authorization header was stripped + $response.Content.Headers -contains "Authorization" | Should Be $true + } + + It "Validates Invoke-RestMethod strips the authorization header on various redirects" -TestCases $redirectTests { + param($redirectType) + + $response = ExecuteRedirectRequest -Cmdlet 'Invoke-RestMethod' -Uri "http://localhost:8081/PowerShell?test=redirect&type=$redirectType" + + $response.Error | Should BeNullOrEmpty + # ensure user-agent is present (i.e., no false positives ) + $response.Output.Headers -contains "User-Agent" | Should Be $true + # ensure Authorization header has been removed. + $response.Content.Headers -contains "Authorization" | Should Be $false + } + + # NOTE: Only testing redirection of POST -> GET for unique underlying values of HttpStatusCode. + # Some names overlap in underlying value. + It "Validates Invoke-RestMethod strips the authorization header redirects and switches from POST to GET when it handles the redirect" -TestCases $redirectTests { + param($redirectType, $redirectedMethod) + + $response = ExecuteRedirectRequest -Cmdlet 'Invoke-RestMethod' -Uri "http://localhost:8081/PowerShell?test=redirect&type=$redirectType" -Method 'POST' + + $response.Error | Should BeNullOrEmpty + # ensure user-agent is present (i.e., no false positives ) + $response.Content.Headers -contains "User-Agent" | Should Be $true + # ensure Authorization header has been removed. + $response.Content.Headers -contains "Authorization" | Should Be $false + # ensure POST was changed to GET for selected redirections and remains as POST for others. + $response.Content.HttpMethod | Should Be $redirectedMethod + } + + #endregion Redirect tests + BeforeEach { if ($env:http_proxy) { $savedHttpProxy = $env:http_proxy diff --git a/test/tools/Modules/HttpListener/HttpListener.psm1 b/test/tools/Modules/HttpListener/HttpListener.psm1 index becb21ac80..e38afff138 100644 --- a/test/tools/Modules/HttpListener/HttpListener.psm1 +++ b/test/tools/Modules/HttpListener/HttpListener.psm1 @@ -46,6 +46,15 @@ Function Start-HTTPListener { [scriptblock]$script = { param ($Port) + $script:supportedRedirects = @{ + [System.Net.HttpStatusCode]::Ok = 1; # No redirect + [System.Net.HttpStatusCode]::Found = 1; + [System.Net.HttpStatusCode]::MultipleChoices = 1; + [System.Net.HttpStatusCode]::Moved = 1; + [System.Net.HttpStatusCode]::SeeOther = 1; + [System.Net.HttpStatusCode]::TemporaryRedirect = 1; + } + # HttpListener.QueryString is not being populated, need to follow-up with CoreFx, workaround is to parse it ourselves Function ParseQueryString([string]$url) { @@ -92,6 +101,11 @@ Function Start-HTTPListener { $test = $queryItems["test"] Write-Verbose "Testing: $test" + foreach ($key in $request.Headers.Keys) + { + Write-Verbose -Message "Found Header: $key, $($request.Headers[$key])" + } + # the status code to return for the response $statusCode = [System.Net.HttpStatusCode]::OK # this is the body of the response, return json/xml as appropriate @@ -120,21 +134,75 @@ Function Start-HTTPListener { $contentType = $queryItems["contenttype"] $output = $queryItems["output"] } + + <# + This test provides support for multiple redirection types as well as a custom + multi-hop redirection to handle Authorization stripping logic. + The following redirection types are supported: + MultipleChoices (300), Moved (301), Found (302), SeeOther (303), TemporaryRedirect (307) + + The original URL should indicate the type of redirection. + For example: The following indicates that a 302 redirection (found) should be used. + ?test=redirectex&type=Found + + WebRequest cmdlet tests also use a special option called multiredirect. This produces two redirects + where the second + + Example: test=redirectex&type=Moved&multiredirect=true + + See also https://msdn.microsoft.com/en-us/library/system.net.httpstatuscode(v=vs.110).aspx + #> "redirect" { - $redirect = $queryItems["redirect"] - if ($redirect -eq $null) + $redirectedUrl = [string]::Empty + $redirectType = $queryItems["type"] + $multiredirect = $queryItems["multiredirect"] + + if ($redirectType -eq $null) { - $statusCode = [System.Net.HttpStatusCode]::Found - $redirectedUrl = "${Url}?test=redirect&redirect=false" - Write-Verbose "$redirectedUrl" - $outputHeader.Add("Location",$redirectedUrl) - Write-Verbose "Redirecting to $($outputHeader.Location)" + # End of redirection + $redirectType = 'Ok' } - else + + [System.Net.HttpStatusCode] $type = [System.Net.HttpStatusCode]::Found + [bool] $isValid = [System.Enum]::TryParse($redirectType, $true, [ref] $type) + if ($isValid -eq $false -or $script:supportedRedirects.ContainsKey($type) -eq $false) { - $output = $request | ConvertTo-Json + Write-Verbose -Message "Invalid request type: $type" + $statusCode = [System.Net.HttpStatusCode]::BadRequest + $output = "Invalid Redirect Type: $type" } + elseif ($type -eq [System.Net.HttpStatusCode]::Ok) + { + # no redirection + Write-Verbose -Message "No redirection" + $output = $request | ConvertTo-Json -Depth 6 + } + elseif ($multiredirect -eq $null) + { + Write-Verbose -Message "Standard redirection" + $redirectedUrl = "${Url}?test=redirect&type=Ok" + } + elseif ($multiredirect -eq $true) + { + Write-Verbose -Message "Redirect 1 of 2" + $redirectedUrl = "${Url}?test=redirect&type=$type&multiredirect=false" + } + elseif ($multiredirect -eq $false) + { + Write-Verbose -Message "Redirect 2 of 2" + $redirectedUrl = "${Url}?test=redirect&type=$type" + } + + if ($isValid) + { + $statusCode = $type + if (-not [string]::IsNullOrEmpty($redirectedUrl)) + { + $outputHeader.Add("Location",$redirectedUrl) + Write-Verbose -Message "Redirecting to $($outputHeader.Location)" + } + } } "linkheader" { @@ -189,11 +257,19 @@ Function Start-HTTPListener { } $response = $context.Response - if ($contentType -ne $null) + + if ($outputHeader.ContainsKey('Content-Type') -eq $false) { - Write-Verbose "Setting ContentType to $contentType" + if ([string]::IsNullOrEmpty($contentType)) + { + $contentType = 'application/json' + } + + $outputHeader.Add('Content-Type', $contentType) $response.ContentType = $contentType + Write-Verbose -Message "Setting ContentType to $contentType" } + if ($statusCode -ne $null) { $response.StatusCode = $statusCode @@ -203,6 +279,7 @@ Function Start-HTTPListener { { $response.Headers.Add($header, $outputHeader[$header]) } + if ($output -ne $null) { $buffer = [System.Text.Encoding]::UTF8.GetBytes($output)