From c4e1598200e6168e90e9fbf8cc781b764efb39cc Mon Sep 17 00:00:00 2001 From: Chris Peterson Date: Mon, 17 Aug 2026 16:59:56 -0700 Subject: [PATCH] Strip auth on rel links only when they leave the origin A rel link is a URL the same server advertised in its own Link header, reached by a client-initiated GET. Routing it through the redirect path dropped the Authorization header the caller supplied for that API, so -FollowRelLink fetched every page after the first anonymously. Against a server that permits anonymous reads the pages still return 200, and the caller gets a visibility-filtered result set with no error and no warning. Compare the followed link's scheme, host and port against the origin the caller requested, and strip only when they differ. Cross-origin links keep the redirect treatment, and -PreserveAuthorizationOnRedirect still overrides both. The same call site is present on release/v7.5.11 and shipped in v7.5.10, so the 7.5 line needs the same change. Refs: https://github.com/PowerShell/PowerShell/issues/27861 --- .../Common/WebRequestPSCmdlet.Common.cs | 12 ++++++-- .../WebCmdlets.Tests.ps1 | 28 +++++++++++++++++-- .../WebListener/Controllers/LinkController.cs | 11 +++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs index f8730fa0ad7..5ee1356757a 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs @@ -554,7 +554,12 @@ protected override void ProcessRecord() HttpClient client = GetHttpClient(handleRedirect); int followedRelLink = 0; - Uri uri = Uri; + + // A rel link is a URL the same server advertised in its own 'Link' header, not a redirect, + // so the credentials the caller supplied still apply. Treat a followed link as a credential + // boundary only when it leaves the origin the caller requested. + Uri uri = CheckProtocol(Uri); + string originAuthority = uri.GetLeftPart(UriPartial.Authority); do { if (followedRelLink > 0) @@ -567,7 +572,10 @@ protected override void ProcessRecord() WriteVerbose(linkVerboseMsg); } - using (HttpRequestMessage request = GetRequest(uri, isRedirect: followedRelLink > 0)) + bool relLinkLeavesOrigin = followedRelLink > 0 + && !string.Equals(originAuthority, uri.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase); + + using (HttpRequestMessage request = GetRequest(uri, isRedirect: relLinkLeavesOrigin)) { FillRequestStream(request); try diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index fec6516053d..506e42fa5c1 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -3029,7 +3029,7 @@ Describe "Invoke-RestMethod tests" -Tags "Feature", "RequireAdminOnWindows" { 1..$maxLinksToFollow | ForEach-Object { $result.Output[$_ - 1].linknumber | Should -BeExactly $_ } } - It "Validate Invoke-RestMethod -FollowRelLink strips the authorization header on followed relation links by default" { + It "Validate Invoke-RestMethod -FollowRelLink keeps the authorization header on relation links within the origin" { $uri = Get-WebListenerUrl -Test 'Link' -Query @{maxlinks = 3} $command = "Invoke-RestMethod -Uri '$uri' -FollowRelLink -Headers @{Authorization = 'test'}" $result = ExecuteWebCommand -command $command @@ -3037,8 +3037,32 @@ Describe "Invoke-RestMethod tests" -Tags "Feature", "RequireAdminOnWindows" { $result.Error | Should -BeNullOrEmpty $result.Output.Count | Should -BeExactly 3 $result.Output[0].headers.Authorization | Should -BeExactly 'test' + $result.Output[1].headers.Authorization | Should -BeExactly 'test' + $result.Output[2].headers.Authorization | Should -BeExactly 'test' + } + + It "Validate Invoke-RestMethod -FollowRelLink strips the authorization header on a relation link that leaves the origin" { + $crossOrigin = Get-WebListenerUrl -Https + $uri = Get-WebListenerUrl -Test 'Link' -Query @{maxlinks = 2; nextauthority = $crossOrigin.GetLeftPart([System.UriPartial]::Authority)} + $command = "Invoke-RestMethod -Uri '$uri' -FollowRelLink -SkipCertificateCheck -Headers @{Authorization = 'test'}" + $result = ExecuteWebCommand -command $command + + $result.Error | Should -BeNullOrEmpty + $result.Output.Count | Should -BeExactly 2 + $result.Output[0].headers.Authorization | Should -BeExactly 'test' $result.Output[1].headers.Authorization | Should -BeNullOrEmpty - $result.Output[2].headers.Authorization | Should -BeNullOrEmpty + } + + It "Validate Invoke-RestMethod -FollowRelLink -PreserveAuthorizationOnRedirect keeps the authorization header across origins" { + $crossOrigin = Get-WebListenerUrl -Https + $uri = Get-WebListenerUrl -Test 'Link' -Query @{maxlinks = 2; nextauthority = $crossOrigin.GetLeftPart([System.UriPartial]::Authority)} + $command = "Invoke-RestMethod -Uri '$uri' -FollowRelLink -PreserveAuthorizationOnRedirect -SkipCertificateCheck -Headers @{Authorization = 'test'}" + $result = ExecuteWebCommand -command $command + + $result.Error | Should -BeNullOrEmpty + $result.Output.Count | Should -BeExactly 2 + $result.Output[0].headers.Authorization | Should -BeExactly 'test' + $result.Output[1].headers.Authorization | Should -BeExactly 'test' } It "Validate Invoke-RestMethod quietly ignores invalid Link Headers if -FollowRelLink is specified: " -TestCases @( diff --git a/test/tools/WebListener/Controllers/LinkController.cs b/test/tools/WebListener/Controllers/LinkController.cs index 6abe9eab2dc..b21234fbc39 100644 --- a/test/tools/WebListener/Controllers/LinkController.cs +++ b/test/tools/WebListener/Controllers/LinkController.cs @@ -78,7 +78,16 @@ public JsonResult Index() if (!skipNextLink && maxLinks > 1 && linkNumber < maxLinks) { - linkList.Add(GetLink(baseUri: baseUri, maxLinks: maxLinks, linkNumber: linkNumber + 1, type: type, whitespace: whitespace, rel: "next")); + // 'nextauthority' advertises the next link on another scheme/host/port, which lets tests + // cover a rel link that crosses an origin. Pass it the authority of another listener port. + string nextBaseUri = baseUri; + if (Request.Query.TryGetValue("nextauthority", out StringValues nextAuthoritySV) + && Uri.TryCreate(nextAuthoritySV.FirstOrDefault(), UriKind.Absolute, out Uri nextAuthority)) + { + nextBaseUri = new Uri(nextAuthority, new Uri(baseUri).AbsolutePath).AbsoluteUri; + } + + linkList.Add(GetLink(baseUri: nextBaseUri, maxLinks: maxLinks, linkNumber: linkNumber + 1, type: type, whitespace: whitespace, rel: "next")); } StringValues linkHeader;