From 4c0cc672a262908512ddf5fdd90d38f87a044c37 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 28 Jul 2026 17:09:21 -0400 Subject: [PATCH] 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 --- plugins/geenforce/api/routes.py | 20 ++- .../geenforce/client/Invoke-ShopdbEnforce.ps1 | 25 ++- .../geenforce/client/ShopdbEnforceClient.psm1 | 22 ++- plugins/geenforce/seed_display_scope.py | 160 ++++++++++++++---- .../test_geenforce_display_seed.py | 37 +++- tests/test_plugins/test_geenforce_manifest.py | 11 ++ 6 files changed, 229 insertions(+), 46 deletions(-) diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index 8e41a3d..63f958a 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -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: ' 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(','): diff --git a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 index 53f2d05..7ffe61f 100644 --- a/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 +++ b/plugins/geenforce/client/Invoke-ShopdbEnforce.ps1 @@ -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). diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 index a9f8fae..7bdce24 100644 --- a/plugins/geenforce/client/ShopdbEnforceClient.psm1 +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -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 } diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py index a257aaf..9d9f47d 100644 --- a/plugins/geenforce/seed_display_scope.py +++ b/plugins/geenforce/seed_display_scope.py @@ -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, } diff --git a/tests/test_plugins/test_geenforce_display_seed.py b/tests/test_plugins/test_geenforce_display_seed.py index 9d58fb7..93a0fa9 100644 --- a/tests/test_plugins/test_geenforce_display_seed.py +++ b/tests/test_plugins/test_geenforce_display_seed.py @@ -11,7 +11,7 @@ from plugins.geenforce.models import ( ) from plugins.geenforce.seed_display_scope import ( seed_display_scope, build_display_manifest, build_dispatcher_script, - DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, + DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME, ) @@ -25,10 +25,10 @@ def test_seed_creates_display_scope(db): # and do NOT inherit common. assert scope.iscommon is False - # Four Registry drift-heal entries + one PS1 dispatcher, in order. - assert summary['entrycount'] == 5 + # Four Registry drift-heal entries + two inline PS1 (dispatcher, always-on). + assert summary['entrycount'] == 6 assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry', - 'Registry', 'PS1'] + 'Registry', 'PS1', 'PS1'] def test_registry_entries_use_valuematches_detection(db): @@ -53,7 +53,8 @@ def test_registry_entries_use_valuematches_detection(db): def test_dispatcher_is_inline_and_data_driven(db): summary = seed_display_scope() - dispatcher = ManifestEntry.query.filter_by(entrytype='PS1').one() + dispatcher = ManifestEntry.query.filter_by( + payloadref=DISPATCHER_FILENAME).one() assert dispatcher.payloadsource == 'inline' assert dispatcher.payloadref == DISPATCHER_FILENAME assert dispatcher.payloadsha256 == summary['dispatchersha256'] @@ -66,6 +67,29 @@ def test_dispatcher_is_inline_and_data_driven(db): assert display_type in scripttext assert route in scripttext assert 'display-type.txt' in scripttext + # H3: SYSTEM writes an all-users Startup shortcut (CreateShortcut/.Save), + # rather than Start-Process-ing Edge (which would open invisibly in session 0). + assert 'CreateShortcut' in scripttext + assert '.Save()' in scripttext + assert 'Startup' in scripttext + # H4: real base URL, not the localhost placeholder. + assert 'localhost' not in scripttext + + +def test_alwayson_entry_is_inline(db): + summary = seed_display_scope() + + alwayson = ManifestEntry.query.filter_by( + payloadref=ALWAYSON_FILENAME).one() + assert alwayson.entrytype == 'PS1' + assert alwayson.payloadsource == 'inline' + assert alwayson.detectionmethod == 'Always' + assert alwayson.payloadsha256 == summary['alwaysonsha256'] + + payload = ManifestPayload.query.filter_by(entryid=alwayson.entryid).one() + scripttext = payload.payloadbytes.decode('utf-8') + assert 'powercfg' in scripttext + assert 'monitor-timeout-ac 0' in scripttext def test_seed_publish_freezes_a_version(db): @@ -87,9 +111,10 @@ def test_seed_draft_is_idempotent(db): assert first['scopeid'] == second['scopeid'] assert first['entrycount'] == second['entrycount'] assert first['dispatchersha256'] == second['dispatchersha256'] + assert first['alwaysonsha256'] == second['alwaysonsha256'] entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all() - assert len(entries) == 5 + assert len(entries) == 6 def test_build_manifest_has_no_smb_exe_payloads(db): diff --git a/tests/test_plugins/test_geenforce_manifest.py b/tests/test_plugins/test_geenforce_manifest.py index 1ebc11b..32c5d1e 100644 --- a/tests/test_plugins/test_geenforce_manifest.py +++ b/tests/test_plugins/test_geenforce_manifest.py @@ -147,6 +147,17 @@ def test_empty_allowlist_keeps_token_required(client, db, app): assert resp.status_code == 401 +def test_spoofed_forwarded_for_does_not_bypass_allowlist(client, db, app): + # SECURITY: the allowlist uses remote_addr, not X-Forwarded-For. A caller + # whose real IP (127.0.0.1) is NOT allowlisted must NOT gain token-less access + # by forging X-Forwarded-For to an allowlisted address. + _seed_and_publish(app) + _set_allowlist(app, '10.134.48.0/23') # test client 127.0.0.1 is NOT in it + resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', + headers={'X-Forwarded-For': '10.134.48.10'}) + assert resp.status_code == 401, 'spoofed X-Forwarded-For bypassed the allowlist' + + def test_wrong_scope_rejected(client, db, app, auth_headers): _seed_and_publish(app) resp = client.post('/api/apitokens',