From 0f68186f97ffefaed9a93c9ff8b83dfac54a7d93 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 12:06:19 +0100 Subject: [PATCH] Stop leaving Appx sideloading policy permanently enabled Installing any MSIX through the module wrote AllowAllTrustedApps=1 to both HKLM\SOFTWARE\Policies\Microsoft\Windows\Appx and AppModelUnlock, and never put them back. Every machine that ever installed an MSIX was left with sideloading permanently enabled, including machines where the policy had previously been explicitly disabled. It also did this unconditionally, before finding out whether it was needed. A correctly signed package usually provisions with no policy change at all. Provisioning is now attempted as the machine is configured. Only if that is refused is the policy relaxed, and it is restored in a finally block that runs on the success path, the per-user fallback path, and the throw. Restoration is exact rather than "set it back to 0": the prior state of each value is recorded first, and the restore puts back the previous value if there was one, removes just the value if the key existed without it, or removes the key entirely if we created it. A pre-existing AllowAllTrustedApps=0 therefore comes back as 0, not as a deleted value. Push-SideloadPolicy deliberately never throws. If it did, the state describing what had already been changed would be lost with it, and the caller's finally block would have nothing to restore from, leaving the policy relaxed. Failures are reported through the returned object instead. Pop-SideloadPolicy indexes its list directly rather than wrapping it in @(). On PowerShell 7.6 / .NET 10, @() over a List[object] throws "Argument types do not match", which aborted the restore and left the policy relaxed. That was caught by the round-trip test below and is the exact failure this change exists to prevent. Verified against a scratch HKCU key across all three prior states: key absent, key present without the value, and key present with AllowAllTrustedApps=0. All three read 1 while relaxed and are byte-identical to their original state afterwards, with an unrelated value in the same key left untouched. --- Private/SideloadPolicy.ps1 | 110 ++++++++++++++++++++++++++++++++++ Public/Install-AppPackage.ps1 | 42 +++++++++---- 2 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 Private/SideloadPolicy.ps1 diff --git a/Private/SideloadPolicy.ps1 b/Private/SideloadPolicy.ps1 new file mode 100644 index 0000000..f6c991e --- /dev/null +++ b/Private/SideloadPolicy.ps1 @@ -0,0 +1,110 @@ +function Push-SideloadPolicy { + <# + .SYNOPSIS + Relaxes the Appx sideloading policy and returns enough state to undo it. + + .DESCRIPTION + Records, for each value it touches, whether the key already existed, + whether the value already existed, and what the value was. Pass the + result to Pop-SideloadPolicy to put the machine back as it was found. + + This deliberately never throws. If it did, the state describing what + had already been changed would be lost with it and the caller's finally + block would have nothing to restore from, leaving the policy relaxed. + Failures are recorded and reported through the returned object instead. + + Paths are parameters so the round trip can be tested against a scratch + key without touching real machine policy. + #> + [CmdletBinding()] + param( + [string[]]$Paths = @( + "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Appx", + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + ), + [string]$ValueName = "AllowAllTrustedApps" + ) + + $Changed = New-Object System.Collections.Generic.List[object] + $Ok = $true + $Errors = New-Object System.Collections.Generic.List[string] + + foreach ($Path in $Paths) { + $KeyExisted = Test-Path $Path + $ValueExisted = $false + $PriorValue = $null + + if ($KeyExisted) { + $Existing = Get-ItemProperty -Path $Path -Name $ValueName -ErrorAction SilentlyContinue + if ($null -ne $Existing) { + $ValueExisted = $true + $PriorValue = $Existing.$ValueName + } + } + + try { + if (-not $KeyExisted) { New-Item -Path $Path -Force -ErrorAction Stop | Out-Null } + + # Record before writing, so anything we manage to change is undoable + # even if a later step fails. + $Changed.Add([PSCustomObject]@{ + Path = $Path + ValueName = $ValueName + KeyExisted = $KeyExisted + ValueExisted = $ValueExisted + PriorValue = $PriorValue + }) + + New-ItemProperty -Path $Path -Name $ValueName -Value 1 -PropertyType DWORD -Force -ErrorAction Stop | Out-Null + } + catch { + $Ok = $false + $Errors.Add("$Path : $($_.Exception.Message)") + } + } + + return [PSCustomObject]@{ + Changed = $Changed + Success = $Ok + Errors = $Errors + } +} + +function Pop-SideloadPolicy { + <# + .SYNOPSIS + Restores whatever Push-SideloadPolicy changed. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [AllowNull()] + $State + ) + + if (-not $State -or -not $State.Changed -or $State.Changed.Count -eq 0) { return } + + # Indexed directly rather than wrapped in @(). On PowerShell 7.6 / .NET 10, + # @() over a List[object] throws "Argument types do not match", which would + # abort the restore and leave sideloading policy relaxed - the exact failure + # this function exists to prevent. + for ($i = $State.Changed.Count - 1; $i -ge 0; $i--) { + $Item = $State.Changed[$i] + + try { + if ($Item.ValueExisted) { + New-ItemProperty -Path $Item.Path -Name $Item.ValueName -Value $Item.PriorValue -PropertyType DWORD -Force -ErrorAction Stop | Out-Null + } + elseif ($Item.KeyExisted) { + Remove-ItemProperty -Path $Item.Path -Name $Item.ValueName -Force -ErrorAction Stop + } + else { + # We created the key, so take the whole thing back out. + Remove-Item -Path $Item.Path -Force -Recurse -ErrorAction Stop + } + } + catch { + Write-PackagerLog -Message "Could not restore sideload policy at $($Item.Path): $($_.Exception.Message)" -Severity Warning + } + } +} diff --git a/Public/Install-AppPackage.ps1 b/Public/Install-AppPackage.ps1 index 8fbf4c8..9bdd35e 100644 --- a/Public/Install-AppPackage.ps1 +++ b/Public/Install-AppPackage.ps1 @@ -31,21 +31,43 @@ function Install-AppPackage { } { $_ -in ".msix", ".appx", ".msixbundle", ".appxbundle" } { Write-PackagerLog -Message "Detected Modern App. Sideloading..." + + # Try provisioning as the machine is configured, before changing + # anything. A correctly signed package normally needs no policy + # change at all, and the previous code relaxed sideloading policy + # unconditionally on every MSIX install and never put it back, + # permanently weakening every machine the module touched. + $Provisioned = $false try { - $PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Appx" - if (-not (Test-Path $PolicyPath)) { New-Item -Path $PolicyPath -Force | Out-Null } - New-ItemProperty -Path $PolicyPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null - - $DevPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - if (-not (Test-Path $DevPath)) { New-Item -Path $DevPath -Force | Out-Null } - New-ItemProperty -Path $DevPath -Name "AllowAllTrustedApps" -Value 1 -PropertyType DWORD -Force | Out-Null - Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null Write-PackagerLog -Message "MSIX Provisioned Successfully." + $Provisioned = $true } catch { - Write-PackagerLog -Message "Provisioning Failed ($($_)). Trying Per-User..." -Severity Warning - try { Add-AppxPackage -Path $FilePath -ErrorAction Stop } catch { throw $_ } + Write-PackagerLog -Message "Provisioning refused under current policy ($($_.Exception.Message)). Relaxing sideloading policy temporarily." -Severity Warning + } + + if (-not $Provisioned) { + $PolicyState = $null + try { + $PolicyState = Push-SideloadPolicy + if (-not $PolicyState.Success) { + Write-PackagerLog -Message "Could not fully relax sideloading policy: $($PolicyState.Errors -join '; ')" -Severity Warning + } + + Add-AppxProvisionedPackage -Online -PackagePath $FilePath -SkipLicense -ErrorAction Stop | Out-Null + Write-PackagerLog -Message "MSIX Provisioned Successfully." + $Provisioned = $true + } + catch { + Write-PackagerLog -Message "Provisioning Failed ($($_.Exception.Message)). Trying Per-User..." -Severity Warning + try { Add-AppxPackage -Path $FilePath -ErrorAction Stop } catch { throw $_ } + } + finally { + # Always, including on the throw above. + Pop-SideloadPolicy -State $PolicyState + Write-PackagerLog -Message "Sideloading policy restored to its previous state." + } } return }