Security hardening, multi-site portability, and a documentation and API-surface sweep

This commit is contained in:
2026-08-14 16:28:34 -04:00
parent 72f8da0e58
commit 265ca2eb01
76 changed files with 3684 additions and 811 deletions

View File

@@ -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"
}