Windows: verify the publisher of the installers we download and run (#8418)

* Windows: verify the publisher of the installers we download and run

Both Windows fallbacks fetch an executable over HTTPS to the temp directory and
Start-Process it immediately: the VC++ runtime in studio/setup.ps1 when winget is
absent or fails, and the python.org installer in install.ps1. HTTPS vouches for the
transfer, not for what arrived, and the process that runs it may be elevated.

Neither URL can be pinned to a committed SHA-256 the way install_node_prebuilt.py
pins the Node archives. aka.ms/vs/17/release is evergreen and its bytes change with
every VS servicing update, and the python.org patch version is resolved at runtime
from the directory listing. Verify the publisher instead.

Checking the Authenticode status alone would not help, since any code-signing
certificate from any trusted CA passes it. The signer subject is checked too, so the
chain has to lead back to Microsoft or the Python Software Foundation.

Both failure paths are the ones already there. The VC++ throw lands in the existing
catch, which prints the same yellow line and falls through to the manual install
instructions, and the python.org check returns $null exactly like the download
failure two lines above it, so the caller still falls back to uv and astral.sh.

* Treat an unreadable signature as a verification failure

Get-AuthenticodeSignature can fail on the file itself rather than on its
signature: antivirus quarantining the download before we inspect it, or the path
becoming unreadable. install.ps1 sets $ErrorActionPreference = "Stop" at the top,
so that error was terminating and escaped Install-PythonFromPythonOrg entirely,
skipping the $null return the caller relies on for its fallback and leaving the
downloaded executable in the temp directory. Confirmed under pwsh: the error
propagates out of the function and the cleanup line never runs.

Unreadable is unverified, so it now takes the same route as a bad signature: a
yellow substep, remove the file, return $null.

setup.ps1 already ran its check inside a try with a finally that removes the
file, so only the install.ps1 path needed this.

Also accept a quoted RDN value in both publisher checks. A subject can arrive as
O="Microsoft Corporation", which the unquoted pattern would have rejected.

* Tighten the comments on the two signature checks
This commit is contained in:
Daniel Han 2026-08-11 05:18:46 -07:00 committed by GitHub
parent da1f829bbb
commit aaf994881b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 43 additions and 1 deletions

View file

@ -2223,6 +2223,24 @@ exit 0
return $null
}
# Same trust boundary as the VC++ runtime in studio/setup.ps1: $full moves per patch
# release, so there is no SHA-256 to pin and the publisher is what we can check.
# Inspection itself can fail (antivirus quarantining the download first), and the
# script-wide 'Stop' would let that escape the function, skipping the $null fallback
# and leaving the executable behind. Unreadable is unverified, so it takes the same
# route as a bad signature.
$sig = $null
try { $sig = Get-AuthenticodeSignature -LiteralPath $dest } catch { $sig = $null }
if ($null -eq $sig -or
$sig.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
$null -eq $sig.SignerCertificate -or
$sig.SignerCertificate.Subject -notmatch '(^|,\s*)O="?Python Software Foundation"?(,|$)') {
$sigStatus = if ($null -eq $sig) { "could not be read" } else { $sig.Status }
substep "python.org installer is not validly signed by the Python Software Foundation (signature status: $sigStatus); not running it." "Yellow"
Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue
return $null
}
# Per-user install => no UAC. PrependPath puts python + py on PATH;
# Include_launcher installs py.exe (preferred by Find-CompatiblePython).
substep "installing Python $full (silent, per-user)..."

View file

@ -1514,6 +1514,16 @@ function Ensure-VCRedist {
} catch { $_prevProtocol = $null }
try {
Invoke-WebRequest -Uri $url -OutFile $dst -UseBasicParsing -TimeoutSec 300
# HTTPS secures the transfer, not the payload, and this runs with the setup
# process's privileges. The evergreen URL rules out a SHA-256 pin (the bytes
# change with every VS servicing update), so check the publisher. Status alone
# is not enough: any trusted CA's code-signing cert passes it.
$sig = Get-AuthenticodeSignature -LiteralPath $dst
if ($sig.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
$null -eq $sig.SignerCertificate -or
$sig.SignerCertificate.Subject -notmatch '(^|,\s*)O="?Microsoft Corporation"?(,|$)') {
throw "the downloaded VC++ runtime is not validly signed by Microsoft (signature status: $($sig.Status))"
}
$p = Start-Process -FilePath $dst -ArgumentList '/quiet', '/norestart' -Wait -PassThru
# 3010 = success, reboot required; usable either way.
if ($p.ExitCode -notin @(0, 3010)) {

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""The direct VC++ runtime download must negotiate TLS 1.2 on legacy protocol defaults."""
"""The direct VC++ runtime download must negotiate TLS 1.2 and run only Microsoft's binary."""
from __future__ import annotations
@ -27,6 +27,20 @@ def _download_block() -> str:
return source[start:end]
def test_the_download_is_verified_as_microsoft_signed_before_it_runs():
# No pwsh needed: Get-AuthenticodeSignature is Windows-only, so the ordering of the three
# steps in the real block is the thing to hold still. A verification placed after
# Start-Process, or one that only checks Status, would still "pass" on a swapped binary.
block = _download_block()
download = block.index("Invoke-WebRequest")
verify = block.index("Get-AuthenticodeSignature", download)
execute = block.index("Start-Process", verify)
assert download < verify < execute
assert "SignatureStatus]::Valid" in block
# Loose on the quoting, since an RDN value may arrive quoted, strict on the publisher.
assert "Microsoft Corporation" in block
def _script(starting_protocol: str) -> str:
# Start from a non-zero set that lacks Tls12. Tls13 is the only such value modern .NET
# accepts, and it stands in for the legacy Ssl3/Tls default of Windows PowerShell 5.1.