geenforce: harden allowlist + fix share-less kiosk client and display scope

- allowlist auth uses remote_addr, not the spoofable first X-Forwarded-For hop
  (adds _trusted_client_ip + a regression test); rate-limit path unchanged
- client psm1: fix Set-StrictMode crashes reading absent keys in Get-ShopdbConfig
  (token-less mode) and Resolve-ShopdbPayloads (no-payload entries); validate
  the manifest response is JSON before overwriting the last-known-good cache
- runner: pass the engine its required -InstallerRoot/-LogFile; create the log
  directory so enforce logging is not silently lost on a fresh kiosk
- display scope: dispatcher writes an all-users Startup shortcut instead of
  Start-Process (SYSTEM cannot show a window in session 0), resolves the base
  URL from HKLM, and adds an always-on power/no-lock entry; tests updated for
  the 6-entry scope
This commit is contained in:
cproudlock
2026-07-28 17:09:21 -04:00
parent f533af82cd
commit 4c0cc672a2
6 changed files with 229 additions and 46 deletions

View File

@@ -46,18 +46,36 @@ REPORT_SCOPE = 'geenforce.report'
ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs'
def _trusted_client_ip():
"""The trustworthy caller IP for the AUTH allowlist.
Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND
to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as
_client_ip does for rate-limiting) would let any caller send
'X-Forwarded-For: <allowlisted-ip>' and bypass the token. remote_addr cannot
be forged here - behind IIS the URL-Rewrite rule overwrites X-Forwarded-For
with the real TCP peer and waitress (--trusted-proxy=127.0.0.1
--trusted-proxy-headers=x-forwarded-for) derives remote_addr from it; a
client hitting waitress directly is not a trusted proxy, so its remote_addr
is its own real peer address. Either way remote_addr is the true client.
"""
return request.remote_addr or ''
def _ip_allowlisted():
"""True when the caller IP falls in the configured geenforce allowlist.
Lets vaulted fleet PCs reach the client endpoints without a per-PC token -
network trust replaces the shared secret. Fails closed: an unparseable
caller IP or malformed allowlist entry never matches. Empty setting = off.
Uses the SPOOF-RESISTANT remote_addr (see _trusted_client_ip), never the raw
X-Forwarded-For header.
"""
raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip()
if not raw:
return False
try:
ip = ipaddress.ip_address(_client_ip())
ip = ipaddress.ip_address(_trusted_client_ip())
except ValueError:
return False
for part in raw.split(','):

View File

@@ -55,7 +55,13 @@ param(
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {}
try {
$logDir = Split-Path -Parent $LogFile
if ($logDir -and -not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue
} catch {}
Write-Host $line
}
@@ -164,8 +170,23 @@ try {
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
# returns into a well-formed summary hashtable so the report stage always
# gets clean input (we do NOT assume the engine was fixed).
# The engine requires -InstallerRoot (base for any relative Source/Installer
# path) and -LogFile. Shadow runs off the share, so relative paths resolve
# against the share scope dir. Cutover rewrites payloads to ABSOLUTE local
# paths, so InstallerRoot is only a harmless fallback base (the payload cache).
if ($ShadowMode -and $ShareManifestPath) {
$installerRoot = Split-Path -Parent $ShareManifestPath
} else {
$installerRoot = Join-Path (Split-Path -Parent $manifestToRun) 'payloads'
}
if ($installerRoot -and -not (Test-Path $installerRoot)) {
New-Item -ItemType Directory -Path $installerRoot -Force -ErrorAction SilentlyContinue | Out-Null
}
$engineLog = $LogFile -replace '\.log$', '-engine.log'
Write-Log "Running engine against $manifestToRun"
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope `
-InstallerRoot $installerRoot -LogFile $engineLog
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
# Report the result (best-effort).

View File

@@ -63,8 +63,10 @@ function Get-ShopdbConfig {
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) {
$props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue
if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl }
if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken }
$regBase = Get-ShopdbProperty $props 'BaseUrl'
$regTok = Get-ShopdbProperty $props 'ApiToken'
if (-not $BaseUrl -and $regBase) { $BaseUrl = [string]$regBase }
if (-not $ApiToken -and $regTok) { $ApiToken = [string]$regTok }
}
# ApiToken is OPTIONAL: on a vaulted network the server may authorize by
# source-IP allowlist, so a BaseUrl alone is a valid config. When a token
@@ -107,6 +109,10 @@ function Sync-ShopdbManifest {
$response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing `
-TimeoutSec 30 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
# Validate JSON before overwriting the last-known-good cache: a proxy
# or IIS error page served as 200 must not clobber the fallback.
try { $null = ($response.Content | ConvertFrom-Json) }
catch { throw "manifest response for $Scope was not valid JSON" }
[System.IO.File]::WriteAllText($manifestPath, $response.Content)
# PowerShell 7 returns header values as string arrays; 5.1 as scalars.
# @(...)[0] yields a clean scalar in both.
@@ -122,7 +128,8 @@ function Sync-ShopdbManifest {
}
} catch {
$status = $null
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
$exResponse = Get-ShopdbProperty $_.Exception 'Response'
if ($exResponse) { $status = [int]$exResponse.StatusCode }
if ($status -eq 304 -and (Test-Path $manifestPath)) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
}
@@ -298,12 +305,13 @@ function Resolve-ShopdbPayloads {
INF='Installer'; PS1='Script'; File='Source' }
$changed = $false
foreach ($entry in @($json.Applications)) {
$src = [string]$entry.PayloadSource
$sha = [string]$entry.PayloadSha256
$src = [string](Get-ShopdbProperty $entry 'PayloadSource')
$sha = [string](Get-ShopdbProperty $entry 'PayloadSha256')
if (-not $sha -or ($src -ne 'http' -and $src -ne 'inline')) { continue }
$field = $pathField[[string]$entry.Type]
$field = $pathField[[string](Get-ShopdbProperty $entry 'Type')]
if (-not $field) { continue }
$local = Get-ShopdbPayload -Sha256 $sha -Config $Config -Filename $entry.PayloadRef -CacheDir $CacheDir
$ref = Get-ShopdbProperty $entry 'PayloadRef'
$local = Get-ShopdbPayload -Sha256 $sha -Config $Config -Filename $ref -CacheDir $CacheDir
if (-not $local) { throw "payload $sha for '$($entry.Name)' could not be fetched/verified" }
if ($entry.PSObject.Properties.Name -contains $field) { $entry.$field = $local }
else { $entry | Add-Member -NotePropertyName $field -NotePropertyValue $local }

View File

@@ -80,15 +80,20 @@ def build_dispatcher_script():
return f"""# Invoke-DisplayKioskDispatch.ps1 -- gea-shopfloor-display dispatcher.
#
# Reads C:\\Enrollment\\display-type.txt and launches the kiosk browser at the
# route mapped for that display subtype. The kiosk browser is baked into the
# display image; this script only points it at the right target.
# Reads C:\\Enrollment\\display-type.txt and ensures an all-users Startup
# shortcut that launches Edge in kiosk fullscreen at the route mapped for that
# subtype. The kiosk browser is baked into the display image; this only points
# the launch at the right target and heals the shortcut on a URL/type change.
#
# WHY a Startup shortcut, not Start-Process: this runs as SYSTEM under the
# enforce task. SYSTEM has no interactive desktop, so Start-Process msedge.exe
# opens INVISIBLY in session 0. A shortcut in the all-users Startup folder is
# instead launched by the auto-login user at logon, in their own visible
# session. Idempotent: prior ShopDB kiosk shortcuts are cleared first, then the
# one for the resolved target is written, so a display-type/URL change self-heals.
#
# The DisplayTypeTargets table below is the single source of truth for the
# subtype -> route map. Edit a row to retarget a subtype.
#
# TODO-confirm: 3DPrintRoom uses the printedparts /parts-kiosk route as a
# PLACEHOLDER. Confirm the real 3D-print-room target before production use.
$ErrorActionPreference = 'Continue'
@@ -97,19 +102,24 @@ $DisplayTypeTargets = @{{
{table_body}
}}
# Base URL the kiosk browser opens; the route from the table is appended. Edit
# to point at this site's shopdb host. Kept here so the map above stays pure.
$KioskBaseUrl = 'https://localhost'
# Base URL the kiosk browser opens; the route from the table is appended. Read
# from HKLM (the value the GE-Enforce client is configured with) so it stays
# site-agnostic; falls back to the West Jefferson shopdb host.
$KioskBaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
try {{
$shopdbConfig = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name BaseUrl -ErrorAction Stop
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
}} catch {{}}
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
Write-Host "display-type.txt not found at $displayTypeFile; nothing to launch."
Write-Host "display-type.txt not found at $displayTypeFile; nothing to configure."
return
}}
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($displayType)) {{
Write-Host 'display-type.txt is empty; nothing to launch.'
Write-Host 'display-type.txt is empty; nothing to configure.'
return
}}
@@ -122,25 +132,91 @@ if (-not $matchedKey) {{
return
}}
$targetRoute = $DisplayTypeTargets[$matchedKey]
$kioskUrl = "$KioskBaseUrl$targetRoute"
Write-Host "display-type '$displayType' -> kiosk target $kioskUrl"
$kioskUrl = "$KioskBaseUrl$($DisplayTypeTargets[$matchedKey])"
# Idempotent: if an Edge kiosk process is already serving this URL, leave it be
# so an enforce cycle does not relaunch the kiosk every run.
$alreadyRunning = Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" -ErrorAction SilentlyContinue |
Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains($kioskUrl) }}
if ($alreadyRunning) {{
Write-Host 'Kiosk already running for this target; leaving it in place.'
return
# Resolve msedge.exe (baked into the display image).
$edge = $null
foreach ($p in @("${{env:ProgramFiles(x86)}}\\Microsoft\\Edge\\Application\\msedge.exe",
"$env:ProgramFiles\\Microsoft\\Edge\\Application\\msedge.exe")) {{
if (Test-Path -LiteralPath $p) {{ $edge = $p; break }}
}}
if (-not $edge) {{ Write-Host 'msedge.exe not found; cannot write kiosk startup shortcut.'; return }}
$edgeArguments = @("--kiosk", $kioskUrl, "--edge-kiosk-type=fullscreen", "--no-first-run")
Start-Process -FilePath 'msedge.exe' -ArgumentList $edgeArguments
Write-Host 'Launched kiosk browser.'
$startup = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\Startup'
if (-not (Test-Path $startup)) {{ New-Item -ItemType Directory -Path $startup -Force | Out-Null }}
# Clear any prior ShopDB kiosk shortcut so a URL/type change self-heals.
Get-ChildItem -LiteralPath $startup -Filter 'ShopDB Kiosk*.lnk' -ErrorAction SilentlyContinue |
ForEach-Object {{ Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }}
$edgeArgs = "--kiosk `"$kioskUrl`" --edge-kiosk-type=fullscreen --no-first-run --no-default-browser-check"
$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk'
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut($lnkPath)
$lnk.TargetPath = $edge
$lnk.Arguments = $edgeArgs
$lnk.WorkingDirectory = Split-Path -Parent $edge
$lnk.WindowStyle = 3
$lnk.Description = "ShopDB kiosk display: $kioskUrl"
$lnk.Save()
Write-Host "display-type '$displayType' -> kiosk Startup shortcut for $kioskUrl"
"""
ALWAYSON_FILENAME = 'Set-DisplayAlwaysOn.ps1'
def build_alwayson_script():
"""Return the inline always-on PowerShell as text.
Keeps a display awake 24/7 (kiosk page always visible): powercfg never-off on
AC+DC, screensaver + auto-lock disabled. Plain raw string - no table
interpolation, so no brace escaping.
"""
return r'''# Set-DisplayAlwaysOn.ps1 -- gea-shopfloor-display always-on.
# Keeps the display awake 24/7 so the kiosk page stays visible: powercfg
# never-off (AC+DC) + screensaver/auto-lock disabled. SYSTEM, every cycle.
$ErrorActionPreference = 'Continue'
foreach ($c in @(
'monitor-timeout-ac 0','monitor-timeout-dc 0',
'standby-timeout-ac 0','standby-timeout-dc 0',
'hibernate-timeout-ac 0','hibernate-timeout-dc 0',
'disk-timeout-ac 0','disk-timeout-dc 0')) {
try { Start-Process -FilePath powercfg -ArgumentList "/change $c" -NoNewWindow -Wait -ErrorAction Stop } catch {}
}
# disable screensaver + secure lock for future profiles (.DEFAULT) + loaded hives.
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
New-PSDrive -Name HKU -PSProvider Registry -Root HKEY_USERS -ErrorAction SilentlyContinue | Out-Null
}
$hives = @('HKU:\.DEFAULT')
try {
Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop |
Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } |
ForEach-Object { $hives += "HKU:\$($_.PSChildName)" }
} catch {}
foreach ($h in $hives) {
$cp = "$h\Control Panel\Desktop"
try {
if (-not (Test-Path $cp)) { New-Item -Path $cp -Force -ErrorAction Stop | Out-Null }
Set-ItemProperty -Path $cp -Name ScreenSaveActive -Value '0' -ErrorAction Stop
Set-ItemProperty -Path $cp -Name ScreenSaverIsSecure -Value '0' -ErrorAction SilentlyContinue
Set-ItemProperty -Path $cp -Name ScreenSaveTimeOut -Value '0' -ErrorAction SilentlyContinue
} catch {}
}
# never auto-lock the kiosk console.
try {
$pol = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization'
if (-not (Test-Path $pol)) { New-Item -Path $pol -Force | Out-Null }
Set-ItemProperty -Path $pol -Name NoLockScreen -Value 1 -Type DWord -ErrorAction SilentlyContinue
} catch {}
Write-Host 'kiosk always-on enforced (power never-off + screensaver/lock disabled).'
'''
def _registry_drift_heal_entry(name, regname, regvalue, regtype, comment):
"""One Type=Registry entry that writes a value and detects drift via
ValueMatches against that same path/name.
@@ -198,13 +274,16 @@ def build_display_manifest():
{
'_comment': (
'Kiosk dispatcher. Reads C:\\Enrollment\\display-type.txt and '
'launches the kiosk target for the subtype. Data-driven map: '
'writes an all-users Startup shortcut that launches Edge kiosk '
'fullscreen at the subtype target. Data-driven map: '
+ _relaunch_window_targets_comment()
+ '. 3DPrintRoom target is a PLACEHOLDER (/parts-kiosk); '
'TODO-confirm the real target. Delivered inline over HTTPS '
'(share-less displays); DetectionMethod Always so it re-asserts '
'each cycle, but the script is idempotent (skips if the kiosk is '
'already serving the target URL).'),
+ '. Base URL from HKLM BaseUrl, else the WJ host. Runs as SYSTEM '
'(no interactive desktop), so it does NOT Start-Process Edge '
'(that opens invisibly in session 0) - the auto-login user runs '
'the shortcut at logon in a visible session. Delivered inline over '
'HTTPS (share-less displays); Always re-asserts, idempotent '
'(rewrites only the resolved-target shortcut, self-heals a '
'type/URL change).'),
'Name': 'Display kiosk dispatcher (display-type.txt)',
'Type': 'PS1',
'Script': DISPATCHER_FILENAME,
@@ -212,6 +291,19 @@ def build_display_manifest():
'PayloadRef': DISPATCHER_FILENAME,
'DetectionMethod': 'Always',
},
{
'_comment': (
'Keep the display awake 24/7 so the kiosk page stays visible: '
'powercfg never-off (AC+DC) + screensaver/auto-lock disabled. '
'Delivered inline over HTTPS; Always re-asserts + self-heals a '
'power-plan drift each cycle.'),
'Name': 'Display always-on (no sleep/blank/lock)',
'Type': 'PS1',
'Script': ALWAYSON_FILENAME,
'PayloadSource': 'inline',
'PayloadRef': ALWAYSON_FILENAME,
'DetectionMethod': 'Always',
},
]
return {
'Version': SCOPE_VERSION,
@@ -248,6 +340,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
dispatcher, DISPATCHER_FILENAME,
'text/plain; charset=utf-8', scriptbytes)
alwayson = next(entry for entry in scope.entries
if entry.name == 'Display always-on (no sleep/blank/lock)')
alwaysonbytes = build_alwayson_script().encode('utf-8')
alwaysonpayload = service.store_inline_payload(
alwayson, ALWAYSON_FILENAME,
'text/plain; charset=utf-8', alwaysonbytes)
publishedversion = None
if publish:
publishedversion = service.publish_scope(
@@ -260,5 +359,6 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
'entrycount': len(scope.entries),
'entrytypes': [entry.entrytype for entry in scope.entries],
'dispatchersha256': payload.payloadsha256,
'alwaysonsha256': alwaysonpayload.payloadsha256,
'publishedversion': publishedversion,
}