Skip to content

Commit c8c13c0

Browse files
authored
Harden registry backup restore against edited backup files (#135)
Backups live in a folder writable without elevation and are restored elevated. A key snapshot without Exists read as "did not exist" and the restore deleted the key; "did not exist" on a key that features only write values in deleted the whole tree; sub-keys under such a key made the restore rebuild the parent tree; and data for values Windows runs as a program was restored unchecked. Require a Boolean Exists, record per snapshot what a "did not exist" restore may delete (the feature's values, or the tree for keys a .reg file deletes outright), reject sub-keys where features capture none, and require program-start data to match Winnow's own .reg data or be confirmed in the restore dialog. Automatic rollback now restores from the in-memory copy instead of re-reading the file.
1 parent 07a675d commit c8c13c0

11 files changed

Lines changed: 611 additions & 26 deletions

‎.github/workflows/test.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ jobs:
144144
exit 1
145145
}
146146
147+
- name: Run registry backup validation tests
148+
shell: pwsh
149+
run: |
150+
$result = Invoke-Pester -Path "Tests/Unit/Test-RegistryBackupValidation.ps1" -Output Detailed -PassThru
151+
if ($result.FailedCount -gt 0) {
152+
Write-Error "$($result.FailedCount) test(s) failed in Test-RegistryBackupValidation.ps1"
153+
exit 1
154+
}
155+
147156
- name: Run preset tests
148157
shell: pwsh
149158
run: |

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ Follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Ve
1515
- In Spanish, the description under the app removal scope dropdown kept saying apps would be removed for all users and from the Windows image, whichever scope was selected. The description was chosen by matching the dropdown text against English, and that text is translated. It now matches the item's name, and the current-user and target-user descriptions are translated too.
1616
- Deferred winget uninstalls never ran. The RunOnce entry was `powershell.exe -EncodedCommand` with a Base64 command, 262 to 366 characters for every app in the catalog, and Windows does not run a RunOnce command longer than 260 characters. This affected the sign-in uninstall of user-scope winget apps, `-User` and `-Sysprep` winget removals, and the Edge uninstall scheduled for `-Sysprep`: each was reported as scheduled and then silently skipped. The entry now starts `winget.exe` directly with the app id as one quoted argument (at most 125 characters for the current catalog), and a command that would still be too long is refused instead of written. RunOnce values are not run through a shell, so characters such as `&` in an id stay literal.
1717

18+
### Security
19+
20+
- Restoring a registry backup is stricter about what the file may do. Backups are saved in a folder the user can write without elevation and are restored elevated, so a changed or damaged file could do more than undo Winnow's changes:
21+
- A key snapshot without `Exists` was read as "did not exist" and the restore deleted the whole key. `Exists` must now be `true` or `false`.
22+
- "Did not exist" on a key that features only write values in (for example `Explorer\Advanced`) deleted the whole key tree. It now removes just those values, and the key only when nothing else is left in it. Keys a feature deletes outright keep the whole-tree restore.
23+
- A snapshot could carry sub-keys under a key that features capture without sub-keys, and restoring it rebuilt the whole tree. That is now rejected.
24+
- Values Windows runs as a program (shell `open\command` verbs, `RunOnce`, COM server and context menu handler registrations) had their data restored as is. Their data must now match what Winnow's own .reg files write; anything else is listed in a warning and restored only if the user confirms it. Automatic rollback after a failed apply restores from the copy held in memory instead of re-reading the file.
25+
1826
## [4.4.4] - 2026-10-17
1927

2028
### Fixed

‎Scripts/Features/BackupRegistryState.ps1‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ function New-RegistrySettingsBackup {
4141
throw "Failed to save registry backup to '$backupFilePath'"
4242
}
4343

44+
# Automatic rollback restores from this copy, not from the file: the Backups
45+
# folder is writable without elevation, and the restore runs elevated.
46+
$script:RunRegistryBackupPayload = $backupConfig
47+
4448
Write-Host "Backup successfully created: $backupFilePath"
4549
Write-Host ""
4650

‎Scripts/Features/InvokeChanges.ps1‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,7 @@ function Invoke-AllChanges {
538538
} | Where-Object { $_ })
539539
# Keep the path: it is what automatic rollback restores from if
540540
# the apply phase fails.
541+
$script:RunRegistryBackupPayload = $null
541542
$script:RunRegistryBackupPath = New-RegistrySettingsBackup -ActionableKeys $applyIds -ExtraFeatures $undoSyntheticFeatures
542543
if ($applyIds -contains 'RemoveApps') {
543544
Write-Host " [INFO] Backing up Component-Based Servicing hive (HKLM\COMPONENTS)..."
@@ -638,7 +639,14 @@ function Invoke-AllChanges {
638639
if (-not [string]::IsNullOrWhiteSpace($script:RunRegistryBackupPath)) {
639640
Write-Host "> $($script:RunRollbackReason). Rolling back registry changes..." -ForegroundColor Yellow
640641
try {
641-
$rollbackBackup = Load-RegistryBackupFromFile -FilePath $script:RunRegistryBackupPath
642+
$rollbackBackup = if ($script:RunRegistryBackupPayload) {
643+
# Same JSON round trip as the file, from this run's memory.
644+
$rollbackSource = $script:RunRegistryBackupPayload | ConvertTo-Json -Depth 25 | ConvertFrom-Json
645+
Normalize-RegistryBackup -Backup $rollbackSource -TrustExecutableValues
646+
}
647+
else {
648+
Load-RegistryBackupFromFile -FilePath $script:RunRegistryBackupPath
649+
}
642650
$rollbackResult = Restore-RegistryBackupState -Backup $rollbackBackup
643651
if ($rollbackResult -and $rollbackResult.Result) {
644652
$registryOutcome = $true

0 commit comments

Comments
 (0)