Skip to content

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

Description

Prerequisites

Steps to reproduce

-FollowRelLink exists to page authenticated REST APIs, GitHub's and GitLab's being the canonical examples. As of 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. Commit e209aea, "Merged PR 41050: [release/v7.6.5] Strip authorization on redirect if -PreserveAuthorizationOnRedirect is not specified", 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;
}

Checking the raw file at each tag, the rel-link call site is absent in v7.5.0, v7.6.0, v7.6.2, v7.6.3, and v7.6.4, and present in v7.6.5. It is also absent from master and release/v7.6 today, so this currently affects the 7.6.5 servicing release only and would reach mainline when the change is ported forward.

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. The published 7.6.5 release notes list a pwsh -file fix, a CI change, and packaging updates; nothing about authorization or redirects, so 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions