From 0c77ef472dc690457f3c302225fd1ac062107f76 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 17 Aug 2026 20:25:55 -0400 Subject: [PATCH 1/3] fix(build): verify the PSGallery source before installing from it A repository named PSGallery is not necessarily the PowerShell Gallery. build.ps1 accepted any existing registration on the strength of its name and then installed every dependency in build.depend.psd1 from it. A registration pointing at another SourceLocation -- a mirror, a proxy, or something deliberately placed -- would have supplied all of them silently. The source URL is now compared against the canonical one and a mismatch throws. Found by review on SrrDBAutomationToolkit and already shipped there and in ScheduledTasksManager. build.ps1 is byte-identical across the remaining four repositories, and this change is a pure ten-line addition to each with nothing else touched. Unsigned at the author's request -- 1Password is unavailable this session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1CarQG8VibNFxw4cN53Zs --- build.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.ps1 b/build.ps1 index a963cf0..a347d1a 100644 --- a/build.ps1 +++ b/build.ps1 @@ -104,6 +104,16 @@ if ($Bootstrap) { throw 'Could not register the PSGallery repository; build dependencies cannot be installed.' } + # A repository named PSGallery is not necessarily the PowerShell Gallery. If one + # is already registered against a different SourceLocation, every dependency in + # build.depend.psd1 would be installed from it on the strength of the name alone. + # Refuse rather than trust the name. + $expectedSourceLocation = 'https://www.powershellgallery.com/api/v2' + $actualSourceLocation = $psGallery.SourceLocation.TrimEnd('/') + if ($actualSourceLocation -ne $expectedSourceLocation.TrimEnd('/')) { + throw "The repository named 'PSGallery' points at [$actualSourceLocation], not [$expectedSourceLocation]. Refusing to install build dependencies from an unexpected source." + } + if ($psGallery.InstallationPolicy -ne 'Trusted') { Set-PSRepository -Name 'PSGallery' -InstallationPolicy 'Trusted' } From 0e4a5887ebc65a7d24cc35e2ca3e703717ec64cf Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 17 Aug 2026 23:48:23 -0400 Subject: [PATCH 2/3] fix(build): identify the gallery by scheme and host, not string equality Review pointed out that -ne is case-insensitive, so a source location differing only in case would pass the check, and suggested -cne. -cne would be wrong in the other direction. Host names are case-insensitive by definition, so a strict comparison rejects https://WWW.PowerShellGallery.com/api/v2 -- which is the real gallery. The identifying part is the host, and a repository standing in for the gallery would differ there, not in casing. The source location is now parsed and its scheme and host compared, which accepts any casing of the genuine host and rejects a different one. Unparseable values are rejected too, where the previous string comparison would have thrown on a null SourceLocation instead. Verified: https://www.powershellgallery.com/api/v2 accepted https://WWW.PowerShellGallery.com/api/V2 accepted https://evil.example.com/api/v2 rejected not-a-uri rejected Unsigned at the author's request -- 1Password is unavailable this session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1CarQG8VibNFxw4cN53Zs --- build.ps1 | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/build.ps1 b/build.ps1 index a347d1a..3dfb3e4 100644 --- a/build.ps1 +++ b/build.ps1 @@ -108,10 +108,19 @@ if ($Bootstrap) { # is already registered against a different SourceLocation, every dependency in # build.depend.psd1 would be installed from it on the strength of the name alone. # Refuse rather than trust the name. - $expectedSourceLocation = 'https://www.powershellgallery.com/api/v2' - $actualSourceLocation = $psGallery.SourceLocation.TrimEnd('/') - if ($actualSourceLocation -ne $expectedSourceLocation.TrimEnd('/')) { - throw "The repository named 'PSGallery' points at [$actualSourceLocation], not [$expectedSourceLocation]. Refusing to install build dependencies from an unexpected source." + # Compare scheme and host rather than the whole string. What identifies the + # gallery is the host, and a plain -ne is case-insensitive while -cne would be + # wrong in the other direction: host names are case-insensitive by definition, + # so -cne would reject https://WWW.PowerShellGallery.com/api/v2, which is the + # real gallery. Parsing sidesteps both, and an attacker-controlled repository + # would differ by host, which is what this actually checks. + $expectedSource = [Uri]'https://www.powershellgallery.com/api/v2' + $actualSource = $psGallery.SourceLocation -as [Uri] + $sourceIsExpected = $null -ne $actualSource -and + $actualSource.Scheme -eq $expectedSource.Scheme -and + $actualSource.Host -eq $expectedSource.Host + if (-not $sourceIsExpected) { + throw "The repository named 'PSGallery' points at [$($psGallery.SourceLocation)], which is not $($expectedSource.Scheme)://$($expectedSource.Host). Refusing to install build dependencies from an unexpected source." } if ($psGallery.InstallationPolicy -ne 'Trusted') { From c9f5aa9f0462344110e7f1b89a7bd2d131592c9a Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Tue, 18 Aug 2026 10:49:23 -0400 Subject: [PATCH 3/3] fix(build): compare the gallery port as well as scheme and host Review noted that comparing scheme and host alone accepts https://www.powershellgallery.com:444/api/v2. A non-default port on the same name reaches a different listener, so that is a real gap. Port is now compared too; [Uri] supplies 443 for https when none is given, so the canonical URL and an explicit :443 both match. The path is still deliberately not compared, and the comment now says why: the gallery serves both /api/v2 and /api/v3, and any path on the genuine host is still the genuine host. Host and port are the trust boundary, so pinning the path would only add a false rejection for a legitimate registration. The three stacked comment blocks this accumulated over successive reviews are consolidated into one. Verified: https://www.powershellgallery.com/api/v2 accepted https://WWW.PowerShellGallery.com/api/V2 accepted https://www.powershellgallery.com:443/api/v2 accepted https://www.powershellgallery.com/other-path accepted https://www.powershellgallery.com:444/api/v2 rejected http://www.powershellgallery.com/api/v2 rejected https://evil.example.com/api/v2 rejected not-a-uri rejected Unsigned at the author's request -- 1Password is unavailable this session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1CarQG8VibNFxw4cN53Zs --- build.ps1 | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/build.ps1 b/build.ps1 index 3dfb3e4..ae5de41 100644 --- a/build.ps1 +++ b/build.ps1 @@ -107,20 +107,29 @@ if ($Bootstrap) { # A repository named PSGallery is not necessarily the PowerShell Gallery. If one # is already registered against a different SourceLocation, every dependency in # build.depend.psd1 would be installed from it on the strength of the name alone. - # Refuse rather than trust the name. - # Compare scheme and host rather than the whole string. What identifies the - # gallery is the host, and a plain -ne is case-insensitive while -cne would be - # wrong in the other direction: host names are case-insensitive by definition, - # so -cne would reject https://WWW.PowerShellGallery.com/api/v2, which is the - # real gallery. Parsing sidesteps both, and an attacker-controlled repository - # would differ by host, which is what this actually checks. + # + # Compare the parsed URI's scheme, host and port rather than the string: + # + # - String equality with -ne is case-insensitive, so a differently cased value + # slipped through. Switching to -cne would be wrong in the other direction, + # since host names are case-insensitive by definition and -cne would reject + # https://WWW.PowerShellGallery.com/api/v2, which is the real gallery. + # - Port matters: a non-default port on the same name reaches a different + # listener. [Uri] supplies 443 for https when none is given, so the canonical + # URL and an explicit :443 both match, while :444 does not. + # - The path is deliberately not compared. The gallery serves both /api/v2 and + # /api/v3, and any path on the genuine host is still the genuine host. Host + # and port are the trust boundary; pinning the path would only add a false + # rejection for a legitimate registration. $expectedSource = [Uri]'https://www.powershellgallery.com/api/v2' $actualSource = $psGallery.SourceLocation -as [Uri] $sourceIsExpected = $null -ne $actualSource -and $actualSource.Scheme -eq $expectedSource.Scheme -and - $actualSource.Host -eq $expectedSource.Host + $actualSource.Host -eq $expectedSource.Host -and + $actualSource.Port -eq $expectedSource.Port if (-not $sourceIsExpected) { - throw "The repository named 'PSGallery' points at [$($psGallery.SourceLocation)], which is not $($expectedSource.Scheme)://$($expectedSource.Host). Refusing to install build dependencies from an unexpected source." + $expectedAuthority = '{0}://{1}:{2}' -f $expectedSource.Scheme, $expectedSource.Host, $expectedSource.Port + throw "The repository named 'PSGallery' points at [$($psGallery.SourceLocation)], which is not $expectedAuthority. Refusing to install build dependencies from an unexpected source." } if ($psGallery.InstallationPolicy -ne 'Trusted') {