Lock down backup directory ACLs, and let the uninstaller reach IIS
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s

Two findings from the installer review, both of which fail silently.

Database dumps were readable by every authenticated user. A directory created
under ProgramData inherits BUILTIN\Users:(I)(OI)(CI)(RX), and a dump contains
every row including the users table and its password hashes. The installer
applied an owner-only ACL, but only in the branch that CREATED the directory,
so a directory created first by the console (shopdb-admin.ps1 backup) kept the
inherited permissions and the installer could never repair it. The ACL is now
re-applied on every run rather than only on creation, and the grants are made
inheritable with (OI)(CI) so dumps written into the directory later are covered
too. shopdb-admin.ps1 applies the same hardening for the default location, and
for an operator-named path says the dump holds password hashes rather than
silently rewriting the ACL of a directory that is theirs.

Verified on Windows: before, the directory carried BUILTIN\Users:(I)(OI)(CI)
(RX); after, only SYSTEM and Administrators, and a file created inside inherits
exactly those two. Without (OI)(CI) that file would not have been covered.

The uninstaller could not remove anything in IIS. [UninstallRun] launched a
bare "powershell.exe", and the Inno uninstaller is a 32-bit process, so WOW64
resolved it to the 32-bit PowerShell, which cannot see the IIS provider. The
site, application pool and application survived, pointing at a directory that
HAD been deleted, while Windows reported a clean uninstall. It now uses the
same Sysnative path as the [Run] entry, which was the last unshielded launch
site in the file.
This commit is contained in:
cproudlock
2026-08-04 21:04:54 -04:00
parent 1d73bd477e
commit ce521e84a5
3 changed files with 44 additions and 5 deletions

View File

@@ -1206,6 +1206,12 @@ end;
; Delegate to the same tested script rather than duplicating removal logic here. ; Delegate to the same tested script rather than duplicating removal logic here.
; It removes the site, app pool, firewall rule and application directory, and ; It removes the site, app pool, firewall rule and application directory, and
; deliberately does NOT drop the database or uninstall MySQL. ; deliberately does NOT drop the database or uninstall MySQL.
Filename: "powershell.exe"; \ ;
; Sysnative, for the same reason as the [Run] entry above: the uninstaller is a
; 32-bit process, so a bare "powershell.exe" is resolved through WOW64 to the
; 32-bit PowerShell, which cannot see the IIS provider at all. Removal then
; skipped the site, application pool and application - leaving them pointing at
; a directory it HAD deleted - while Windows reported a clean uninstall.
Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \
RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden

View File

@@ -372,9 +372,28 @@ function Backup-Db {
Head 'Database backup' Head 'Database backup'
$db = Get-DbParts $db = Get-DbParts
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return } if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
$usingDefault = -not $Dest
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' } if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null } if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
# A dump holds every row, including the users table and its password hashes.
# A directory created under ProgramData INHERITS Users:RX, so those hashes
# were readable by every authenticated user on the server whenever this
# command created the directory rather than the installer.
#
# Re-applied on every backup, not only on creation, because this may be
# repairing a directory made by an earlier version.
#
# Only for the default location. A path the operator named is theirs, and
# silently rewriting its ACL is not this command's business - say so instead.
if ($usingDefault) {
& icacls.exe $Dest '/inheritance:r' `
'/grant' 'BUILTIN\Administrators:(OI)(CI)(F)' `
'/grant' 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)' 2>&1 | Out-Null
} else {
Say ' note: this dump contains password hashes - check who can read that directory' 'Yellow'
}
$mysql = Find-MysqlClient $mysql = Find-MysqlClient
if (-not $mysql) { Say ' mysql client not found' 'Red'; return } if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe' $dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'

View File

@@ -279,9 +279,18 @@ function Invoke-Native {
function Protect-File { function Protect-File {
# Owner-only ACL: Administrators + SYSTEM, inheritance broken. # Owner-only ACL: Administrators + SYSTEM, inheritance broken.
param([string] $Path) #
Invoke-Native 'icacls.exe' @($Path,'/inheritance:r', # -Directory additionally makes the two grants INHERITABLE. Without (OI)(CI)
'/grant','BUILTIN\Administrators:(F)','/grant','NT AUTHORITY\SYSTEM:(F)') 'ACL' # the grants apply to the directory alone, so files written into it later -
# backups taken by the console, not by this script - are not covered.
param([string] $Path, [switch] $Directory)
$admins = 'BUILTIN\Administrators:(F)'
$system = 'NT AUTHORITY\SYSTEM:(F)'
if ($Directory) {
$admins = 'BUILTIN\Administrators:(OI)(CI)(F)'
$system = 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)'
}
Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL'
} }
function New-Secret { function New-Secret {
@@ -546,8 +555,13 @@ function Backup-Database {
$dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups' $dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups'
if (-not (Test-Path $dir)) { if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null New-Item -ItemType Directory -Path $dir -Force | Out-Null
Protect-File $dir
} }
# RE-APPLIED EVERY TIME, not only on creation. The console creates this
# directory too (shopdb-admin.ps1 backup), and one created there inherits
# ProgramData's Users:RX - so dumps containing the users table and its
# password hashes became readable by every authenticated user on the server.
# The old create-only guard meant the installer could never repair that.
Protect-File $dir -Directory
$file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss')) $file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss'))
Write-Log "backing up $($db.Name) before migrating" Write-Log "backing up $($db.Name) before migrating"