diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 index b3ffb52..afe9e05 100644 --- a/deploy/windows/installer/shopdb-install.ps1 +++ b/deploy/windows/installer/shopdb-install.ps1 @@ -436,6 +436,43 @@ function Protect-File { Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL' } +function Protect-InstallRoot { + # A directory created directly under C:\ INHERITS the root's DACL, and the + # default C:\ DACL carries an inherit-only Modify grant for Authenticated + # Users. So C:\shopdb-flask, C:\Python314 and C:\MySQL84 are writable by + # every logged-on user until inheritance is broken. + # + # That is a local privilege escalation here specifically, because this + # installer puts two things a non-admin could then replace inside those + # roots: shopdb-admin.ps1, which self-elevates with -Verb RunAs, and + # instance\config.py, which the app loads unconditionally through + # from_pyfile. Overwrite either, wait for an administrator or the app pool + # to run it, and the code runs as them. + # + # RE-APPLIED EVERY TIME, not only on creation - same reasoning as the + # ProgramData backups directory. An upgrade over an installation that + # predates this must repair the ACL, or the hole survives the fix. + # + # The app pool's own RX/Modify grants are applied in stage 4, AFTER this, + # so breaking inheritance here does not lock the site out of its own tree. + param([string] $Path) + if (-not (Test-Path $Path)) { return } + Write-Log "hardening $Path (breaking inheritance from its drive root)" + Protect-File $Path -Directory +} + +function Test-UserWritableRoot { + # Whether a non-admin can write to a path. Used ONLY on roots this + # installer did not create: hardening a Python another application already + # depends on would break that application, so a pre-existing root is + # reported rather than seized. Silence would be worse - the app would be + # running on an interpreter any user can replace. + param([string] $Path) + if (-not (Test-Path $Path)) { return $false } + $acl = & icacls.exe $Path 2>$null + return [bool]($acl -match '(Authenticated Users|BUILTIN\\Users|Everyone):\([^)]*\)?\(?(M|F|W)\)') +} + function New-Secret { param([int] $Bytes = 48) $b = New-Object byte[] $Bytes @@ -461,6 +498,9 @@ function Test-SamePath { } # Paths derived once. +# Default so a stage that runs on its own still knows where the interpreter is; +# the Python stage overwrites it with what it actually installed. +$script:PyRoot = 'C:\Python314' $Py = Join-Path $AppRoot 'venv\Scripts\python.exe' $Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' $Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe' @@ -956,6 +996,10 @@ Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand. # bootstrapper returned 0 while installing to the wrong directory. Verify. if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" } Track 'mysql-files' $MysqlRoot + # The service is registered with `mysqld --install` and no obj=, so it runs + # as LocalSystem - the SYSTEM grant covers it. Nothing else needs to write + # here; a user who can replace mysqld.exe owns the service account. + Protect-InstallRoot $MysqlRoot Write-Log 'MySQL binaries installed' 'OK' # --- config ------------------------------------------------------------- @@ -1003,6 +1047,11 @@ data, STOP: you are about to destroy a database. Write-Log 'initializing data directory' Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' Track 'mysql-datadir' $MysqlDataDir + # The data directory holds the users table and its password hashes, and it + # sits under ProgramData, which grants Users read by inheritance - the same + # exposure the backup directory already had to be locked against. mysqld + # runs as LocalSystem, so Administrators + SYSTEM is sufficient. + Protect-File $MysqlDataDir -Directory # --- service ------------------------------------------------------------ # Argument ORDER MATTERS: --install BEFORE --defaults-file. Verified @@ -1048,6 +1097,9 @@ directory to install cleanly. # with default ACLs and populated afterwards is readable in the gap. $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql' New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null + # Before the file goes in: this may be the first thing that creates AppRoot, + # and the file below holds the root and app database passwords. + Protect-InstallRoot $AppRoot New-Item -ItemType File -Force -Path $sqlPath | Out-Null Protect-File $sqlPath $sql = @" @@ -1150,8 +1202,17 @@ function Invoke-Stage2 { # Same class of failure as MySQL's --defaults-file. Never put a space in a # path this installer controls. $pyTarget = 'C:\Python314' + # Stage 4 needs it to grant the app pool read access; a venv's python.exe is + # a copy, but python314.dll and the stdlib still come from here. + $script:PyRoot = $pyTarget if (Test-Path (Join-Path $pyTarget 'python.exe')) { Write-Log "Python already present at $pyTarget" 'OK' + # NOT hardened: something else installed it and may depend on the ACL it + # has. Say so instead - the app would otherwise run on an interpreter + # any logged-on user can replace. + if (Test-UserWritableRoot $pyTarget) { + Write-Log "$pyTarget is writable by non-administrators; this installer did not create it, so it is left alone. Harden it or reinstall Python under an ACL only administrators can write." 'WARN' + } } elseif ($null -eq $pyInstaller) { Fail "no Python installer in $BundleRoot\python" } else { @@ -1164,6 +1225,9 @@ function Invoke-Stage2 { 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" ) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641 Track 'python' $pyTarget + # Ours, so ours to lock down. InstallAllUsers=1 puts it at C:\Python314 + # with the drive root's inherited Authenticated Users Modify. + Protect-InstallRoot $pyTarget } } $sysPy = Join-Path $pyTarget 'python.exe' @@ -1174,6 +1238,7 @@ function Invoke-Stage2 { New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null Track 'dir' $AppRoot } + Protect-InstallRoot $AppRoot # --- upgrade detection --------------------------------------------------- $script:BundleVersion = Get-BundleVersion $script:InstalledVersion = Get-InstalledVersion @@ -2022,6 +2087,13 @@ has to come from the bundle either way. $ident = "IIS AppPool\$AppPool" Write-Log "granting $ident access" Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' + # The Python root no longer inherits from the drive root, so the pool has + # no access to it by default. The venv's python.exe is a copy; the DLL + # and the standard library are still read from the base install, so + # without this grant the site 500s on every request. + if ($script:PyRoot -and (Test-Path $script:PyRoot)) { + Invoke-Native 'icacls.exe' @($script:PyRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on Python root' + } foreach ($sub in @('logs','instance')) { Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" }