Skip to content

Invoke-RestMethod -FollowRelLink strips the Authorization header on followed links in 7.6.5 #27861

Description

Steps to reproduce

-FollowRelLink exists to page authenticated REST APIs, GitHub's and GitLab's being the canonical examples. As of the 2026-08-14 servicing releases — 7.4.19, 7.5.10 and 7.6.5 — the cmdlet sends the caller's Authorization header on the first request only; every page reached by following a rel="next" link is fetched anonymously.

Against a server that permits anonymous reads, those requests still succeed. They just return a smaller, visibility-filtered result set, with HTTP 200 and no warning, so the caller receives silently partial data rather than an error. (Against a fully private server the follow fails loudly with 401/404 instead, which is presumably why this hasn't been widely reported yet.)

What changed. One change, landed on each servicing line through its own Azure DevOps PR — "Strip authorization on redirect if -PreserveAuthorizationOnRedirect is not specified". On 7.6 that is commit e209aea (PR 41050), which changed WebRequestPSCmdlet.Common.cs#L570:

-  using (HttpRequestMessage request = GetRequest(uri))
+  using (HttpRequestMessage request = GetRequest(uri, isRedirect: followedRelLink > 0))

GetRequest then drops the header:

if (isRedirect && !PreserveAuthorizationOnRedirect && entry.Key is HttpKnownHeaderNames.Authorization)
{
    continue;
}

Impacted Versions

Released version Affected Rel-link call site Introducing commit
v7.4.19 (LTS) yes L565 d00974dc7 — PR 41040
v7.5.10 yes L565 0c59ffd11 — PR 41045
v7.6.5 yes L570 e209aea1d — PR 41050
v7.7.0-preview.4 yes L570 91448ff15 — #27873
v7.4.18, v7.5.9, v7.6.4, v7.7.0-preview.3 and earlier no — —
7.9.x-preview no — —

So anyone paging an authenticated API is affected on whichever line they upgraded to, the LTS and the 7.7 preview included.

Branches, as they stand today.

Branch Affected Rel-link call site
master yes L570
release/v7.4.20 yes L565
release/v7.5.11 yes L565
release/v7.6.6 yes L570
release/v7.7.0-preview.4, release/v7.7.0-preview.5 yes L570
release/v7.4.94, release/v7.4.95 yes L565
release/v7.4.99, release/v7.5.99, release/v7.6.99, release/v7.9.99-preview.109 no —

91448ff15 (#27873) brought the call site to master on 2026-08-20, so 7.7 and everything after it inherit the behavior. A fix has to land on master and be backported to each servicing line above.

I recognize this looks deliberate — the same commit added a test asserting it:

Validate Invoke-RestMethod -FollowRelLink strips the authorization header on followed relation links by default

So the question isn't whether the code does what it was written to do. It's whether the cost to authenticated pagination was weighed when redirect semantics were extended to -FollowRelLink.

The argument for treating them differently: a rel-link follow is not a redirect. It is a client-initiated GET to a URL the same server advertised in its own Link header, and in practice it is same-origin. The conventional rule for credential stripping is to drop them when crossing an origin, not on every hop. Applied unconditionally here, it removes credentials the caller supplied deliberately for the API being paged, which leaves -FollowRelLink unable to serve the use case it was added for.

Repro — self-contained, no external service, runs anywhere pwsh runs. It serves three linked pages from HttpListener and has each page report whether the request that fetched it carried an Authorization header.

# Standalone repro: -FollowRelLink drops the Authorization header on followed links.
# No external service required; serves three linked pages from HttpListener on localhost.
# Each page reports whether the request that fetched it carried an Authorization header.

$port = 8802
$prefix = "http://localhost:$port/"

$listener = Start-ThreadJob -ArgumentList $prefix -ScriptBlock {
    param($prefix)
    $l = [System.Net.HttpListener]::new()
    $l.Prefixes.Add($prefix)
    $l.Start()
    for ($i = 1; $i -le 3; $i++) {
        $ctx = $l.GetContext()
        $page = 1
        if ($ctx.Request.Url.Query -match 'page=(\d+)') { $page = [int]$Matches[1] }
        $auth = $ctx.Request.Headers['Authorization']

        $body = @{ page = $page; authorization = $auth } | ConvertTo-Json -Compress
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($body)

        if ($page -lt 3) {
            $next = $prefix.TrimEnd('/') + "/?page=$($page + 1)"
            $ctx.Response.AddHeader('Link', "<$next>; rel=`"next`"")
        }
        $ctx.Response.ContentType = 'application/json'
        $ctx.Response.ContentLength64 = $bytes.Length
        $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
        $ctx.Response.Close()
    }
    $l.Stop()
}

Start-Sleep -Milliseconds 700

$result = Invoke-RestMethod -Uri "$prefix`?page=1" -Headers @{ Authorization = 'test' } -FollowRelLink -MaximumFollowRelLink 10

Write-Host "PowerShell $($PSVersionTable.PSVersion)"
foreach ($r in $result) {
    $seen = if ([string]::IsNullOrEmpty($r.authorization)) { '<none>' } else { $r.authorization }
    Write-Host ("  page {0}: server saw Authorization = {1}" -f $r.page, $seen)
}

$null = Receive-Job $listener -Wait -ErrorAction SilentlyContinue
Remove-Job $listener -Force -ErrorAction SilentlyContinue

Impact against a real API. On a self-managed GitLab 18.11.6-ee group holding 43 projects (39 public, 4 internal), Invoke-RestMethod -FollowRelLink returned exactly 39 at every page size tried (per_page 5, 10, and 20): page 1 came back authenticated, every later page anonymous. The same call on 7.6.4, same machine and endpoint, returns 43. It reached us through the GitlabCli module, where Get-GitlabProject -GroupId <group> -Recurse -All silently returned 39 of 43 projects.

-PreserveAuthorizationOnRedirect does restore the full result, but it is the same switch that governs genuine cross-origin redirects, so it isn't a safe thing for a shared module to turn on just to page an API.

Remedies, in the order we'd prefer them. Sketches against the v7.6.5 source to make the ask concrete — untested, and offered as a direction rather than a patch. Happy to open a PR for whichever shape you'd accept.

1. Strip only when the followed link leaves the origin. This keeps the security fix's intent for the case it was written for, and restores authenticated pagination for the same-origin case that is essentially all of it:

                 int followedRelLink = 0;
                 Uri uri = Uri;
+                string originAuthority = uri.GetLeftPart(UriPartial.Authority);
                 do
                 {
@@
-                    using (HttpRequestMessage request = GetRequest(uri, isRedirect: followedRelLink > 0))
+                    // A rel-link follow targets a URL the same server advertised in its own Link
+                    // header. Treat it as a credential boundary only when it leaves that origin.
+                    bool relLinkLeavesOrigin = followedRelLink > 0
+                        && !string.Equals(
+                               originAuthority,
+                               uri.GetLeftPart(UriPartial.Authority),
+                               StringComparison.OrdinalIgnoreCase);
+
+                    using (HttpRequestMessage request = GetRequest(uri, isRedirect: relLinkLeavesOrigin))

2. If the unconditional strip stays, make it audible. The failure is silent in both halves — the header vanishes without comment, and the paging loop then exits on a bare return — so nothing distinguishes a truncated read from a complete one:

                             uri = new Uri(_relationLink["next"]);
                             followedRelLink++;
+
+                            if (!PreserveAuthorizationOnRedirect
+                                && WebSession.Headers.ContainsKey(HttpKnownHeaderNames.Authorization))
+                            {
+                                WriteWarning(WebCmdletStrings.AuthorizationStrippedOnRelLink);
+                            }

(As written that warns once per followed page; hoisting it to fire once per command would be quieter.)

3. Either way, document the behavior change. None of the three published release notes — 7.4.19, 7.5.10, 7.6.5 — mention authorization or redirects; they list a pwsh -file fix, test and CI changes, and packaging updates. There is no signal connecting an upgrade to newly-truncated results.

Expected behavior

PowerShell 7.6.4
  page 1: server saw Authorization = test
  page 2: server saw Authorization = test
  page 3: server saw Authorization = test

Actual behavior

PowerShell 7.6.5
  page 1: server saw Authorization = test
  page 2: server saw Authorization = <none>
  page 3: server saw Authorization = <none>

Error details

None. That is the substance of the report: the followed requests succeed, so the caller gets a well-formed short result with no error, no warning, and no non-zero status.

Environment data

Name                           Value
----                           -----
PSVersion                      7.6.5
PSEdition                      Core
GitCommitId                    7.6.5
OS                             macOS 26.6.1
Platform                       Unix
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.4
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

Reproduced on macOS 26.6.1 arm64. Confirmed absent on 7.6.4 on the same machine against the same endpoints.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Issue-Questionideally support can be provided via other mechanisms, but sometimes folks do open an issue to get aReview - MaintainerThe PR/issue needs a review from the PowerShell repo Maintainers

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions