Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
@@ -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 <name> 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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user