Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
@@ -791,27 +791,62 @@ One-line install and hands a technician one command, which is run in an
|
||||
ELEVATED PowerShell on a PC with no agent:
|
||||
|
||||
```
|
||||
powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
```
|
||||
|
||||
Download then run, never `irm ... | iex`: Microsoft Defender scores the
|
||||
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
|
||||
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
|
||||
every bay runs Defender. The shape of the line is load-bearing too:
|
||||
every bay runs Defender. The rest of the shape is load-bearing too:
|
||||
|
||||
- a literal temp path and no `$` anywhere, because pasting into an
|
||||
already-running PowerShell would otherwise expand `$p` and `$env:TEMP` in the
|
||||
PARENT and hand the child a broken line - as written it means the same in
|
||||
cmd.exe and in PowerShell;
|
||||
- the token rides in the `X-Endpoint-Bootstrap-Token` HEADER. A query string
|
||||
lands verbatim in the IIS and waitress access logs, and a live install token
|
||||
has no business sitting in a log file;
|
||||
- staging is `C:\Program Files\ShopDB\bootstrap`, **not**
|
||||
`C:\Windows\Temp`. That directory grants Users create-file and gives CREATOR
|
||||
OWNER full control of what they create, so any standard user on the bay can
|
||||
pre-plant `C:\Windows\Temp\shopdb-bootstrap.ps1` and wait for a tech to
|
||||
paste the command, which then runs the planted content elevated;
|
||||
- the line re-asserts an admin-only DACL on the directory it creates
|
||||
(`icacls /inheritance:r` granting only the well-known SIDs `S-1-5-32-544` and
|
||||
`S-1-5-18`, never group names, which a rename or a localized install would
|
||||
break), and CHECKS that icacls succeeded: it is a native command, so it
|
||||
reports failure only in an exit code and `$ErrorActionPreference` never
|
||||
applies to it. Exit 6 with a red message, rather than downloading into a
|
||||
directory that still inherits whatever the parent grants;
|
||||
- it refuses (exit 5) to run a staged file owned by anyone but an
|
||||
administrator, `SYSTEM`, or the elevated caller, which is what a pre-planted
|
||||
file looks like;
|
||||
- a literal staging path and no `$` anywhere - including the exit-code read,
|
||||
which is `Get-Variable LASTEXITCODE -ValueOnly`. Pasting into an
|
||||
already-running PowerShell would otherwise expand every `$` in the PARENT and
|
||||
hand the child a broken line. As written it means the same in cmd.exe and in
|
||||
PowerShell;
|
||||
- single quotes for every inner literal, so the outer `-c "..."` is never
|
||||
closed early;
|
||||
- `try`/`finally`, so the dropped file is removed even when the script exits
|
||||
non-zero or the download itself fails. The wrapper's own exit code is the
|
||||
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
|
||||
|
||||
The same URL is offered in the UI as a **Download .ps1** link, for a tech who
|
||||
would rather save the file and run it. Fetching it does not spend a use either
|
||||
way.
|
||||
The same script is offered in the UI as a **Download .ps1** link, for a tech
|
||||
who would rather save the file and run it. That link is the one caller that
|
||||
cannot send a header, so it uses `?t=<token>&via=download`. Fetching the script
|
||||
does not spend a use either way.
|
||||
|
||||
The `?t=` form without `via=download` is accepted ONLY so a bay provisioned
|
||||
before this change - one still running a command pasted when the query form was
|
||||
the published shape - keeps working. It is deprecated: every such fetch logs a
|
||||
warning naming the caller's IP, and that warning is how the rollout finds bays
|
||||
whose command needs re-issuing. Do not publish it, do not paste it into a new
|
||||
bay, and do not build it by hand. A site that has re-issued all of its live
|
||||
commands turns the query form off with
|
||||
`plugin.endpoints.bootstrapquerytoken` = false, which also disables the
|
||||
Download .ps1 link.
|
||||
|
||||
The key is declared in the endpoints plugin's `get_settings_defaults()`, so it
|
||||
seeds as a boolean row, and it is flipped from Settings > Endpoints > Build
|
||||
site agent > One-line install ("Accept the token in the URL"). It is read
|
||||
through the core settings API, not a route of this plugin's own.
|
||||
|
||||
The token is only an authorization to FETCH the installer. The package it
|
||||
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
|
||||
@@ -822,26 +857,41 @@ Admin routes (admin role, audited):
|
||||
|
||||
- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}`
|
||||
(`hours` 1-168, `maxuses` 1-500). Returns
|
||||
`{"secret", "command", "scripturl", "token"}`; `scripturl` is the same token
|
||||
URL the command downloads, for the UI's Download .ps1 link.
|
||||
The secret is in the response exactly once; only its sha256 and a 12-character
|
||||
`tokenprefix` are stored.
|
||||
`{"secret", "command", "scripturl", "token"}`. `command` carries the token in
|
||||
the header; `scripturl` is the same route for the UI's Download .ps1 link, in
|
||||
the `?t=<secret>&via=download` form a browser link can actually use. The
|
||||
`via=download` marker is what keeps an admin's own click out of the
|
||||
un-migrated-bay warning count - it is a hint, not authorization, and nothing
|
||||
is granted by it; what matters is that a command generated before this change
|
||||
cannot carry it. The secret is in the response exactly once; only its sha256
|
||||
and a 12-character `tokenprefix` are stored.
|
||||
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,
|
||||
`usecount` / `maxuses`, `revokedat`, `lastusedat`, `lastusedip` and a `state`
|
||||
of `active`, `expired`, `exhausted` or `revoked`. Never a secret.
|
||||
- `POST /api/endpoints/siteagent/bootstrap/tokens/<tokenid>/revoke`
|
||||
|
||||
Token-authenticated routes. No login; the token is the whole authorization and
|
||||
travels in the query string as `t`, because a PC with no agent has neither a
|
||||
credential nor a user session to put in a header:
|
||||
Token-authenticated routes. No login; the token is the whole authorization,
|
||||
because a PC with no agent has neither a credential nor a user session. It
|
||||
travels in the `X-Endpoint-Bootstrap-Token` header:
|
||||
|
||||
| Route | Spends a use | Returns |
|
||||
|---|---|---|
|
||||
| `GET /api/endpoints/siteagent/bootstrap.ps1?t=...` | no | `text/plain` PowerShell, generated per token |
|
||||
| `GET /api/endpoints/siteagent/bootstrap/package?t=...` | yes | the keyless agent package ZIP |
|
||||
| `GET /api/endpoints/siteagent/bootstrap.ps1` | no | `text/plain` PowerShell, generated per token |
|
||||
| `GET /api/endpoints/siteagent/bootstrap/package` | yes | the keyless agent package ZIP |
|
||||
|
||||
Both also accept `?t=<secret>` while
|
||||
`plugin.endpoints.bootstrapquerytoken` is true (the default), for bays
|
||||
provisioned before the header form and for the UI Download .ps1 link. A query
|
||||
token without `via=download` logs a deprecation warning against the caller's
|
||||
IP; the Download link's own fetches log at info instead and are audited as
|
||||
`(query-string token, admin download link)`, so the warning count stays a list
|
||||
of bays to re-issue. With the setting false, a query token answers **400**
|
||||
naming the header.
|
||||
|
||||
Both answer **404** for a token that is unknown, malformed, expired, revoked or
|
||||
exhausted, so the URL confirms nothing about which tokens exist. Both are
|
||||
exhausted, and for a request that presents no token at all - so the URL
|
||||
confirms nothing about which tokens exist, and a bare probe cannot learn
|
||||
whether this site still accepts the query form. Both are
|
||||
rate-limited per IP (fixed window, `ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX` in
|
||||
`ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS`, 429 `RATE_LIMITED` over
|
||||
budget) and both write an audit row against `EndpointBootstrapToken` carrying
|
||||
@@ -851,7 +901,9 @@ retried paste must not burn the download budget.
|
||||
The generated script:
|
||||
|
||||
- refuses to run unelevated (exit 1) and says to reopen PowerShell as
|
||||
administrator;
|
||||
administrator (the wrapper line has already refused with exit 6 if the
|
||||
staging directory could not be locked down, or exit 5 if the staged file was
|
||||
owned by someone else);
|
||||
- refuses when the agent service already exists (exit 2) and says a managed bay
|
||||
should get the agent from GE-Enforce, unless `SHOPDB_BOOTSTRAP_FORCE` is set
|
||||
to anything but `0` in the same session;
|
||||
|
||||
@@ -9,9 +9,15 @@ and, when the route names a PC, binds the caller through bind_hostname().
|
||||
X-API-Key The shared fleet key (or a collector-scoped managed
|
||||
token). Accepted only while
|
||||
plugin.endpoints.sharedkeyallowed is on, and never for a
|
||||
PC that holds an active credential. A PC blocked
|
||||
from re-enrolling has no active credential, so an
|
||||
older agent on it keeps working on the shared key.
|
||||
PC that holds an active credential. A PC whose
|
||||
credential was REVOKED falls back to the shared key,
|
||||
which is how a rollback to an older agent works and how
|
||||
the un-migrated fleet keeps reporting; a site that has
|
||||
finished migrating turns
|
||||
plugin.endpoints.revokeddeniessharedkey on and a revoked
|
||||
PC is then refused until it enrolls again. A PC that
|
||||
never enrolled has no credential row at all and is
|
||||
never touched by either rule.
|
||||
|
||||
Only the sha256 of a credential is stored. The full secret exists once, in the
|
||||
enroll response.
|
||||
@@ -37,6 +43,12 @@ DISPLAY_PREFIX_LENGTH = 12
|
||||
|
||||
SHARED_KEY_SETTING = 'plugin.endpoints.sharedkeyallowed'
|
||||
AUTO_ENROLL_SETTING = 'plugin.endpoints.autoenroll'
|
||||
# Off by default, which is what this site does today: revoking a credential
|
||||
# drops the PC back to the shared key. Turning it on makes a revoke a real
|
||||
# eviction - the PC reports nothing until it enrolls again - so it is only safe
|
||||
# once the fleet is off the shared key, and it also closes the rollback path
|
||||
# that revoke + blockreenroll exists for.
|
||||
REVOKED_DENIES_SHARED_KEY_SETTING = 'plugin.endpoints.revokeddeniessharedkey'
|
||||
|
||||
CODE_CREDENTIAL_INVALID = 'CREDENTIAL_INVALID'
|
||||
CODE_SHARED_KEY_REFUSED = 'SHARED_KEY_REFUSED'
|
||||
@@ -70,6 +82,12 @@ def auto_enroll_enabled():
|
||||
return Setting.get(AUTO_ENROLL_SETTING, default=True) is not False
|
||||
|
||||
|
||||
def revoked_denies_shared_key():
|
||||
"""True when a revoked credential also bars the PC from the shared key."""
|
||||
return Setting.get(REVOKED_DENIES_SHARED_KEY_SETTING,
|
||||
default=False) is True
|
||||
|
||||
|
||||
def active_credential_for(assetid):
|
||||
from ..models import EndpointCredential
|
||||
|
||||
@@ -81,6 +99,33 @@ def active_credential_for(assetid):
|
||||
.first())
|
||||
|
||||
|
||||
def shared_key_denied_for(assetid):
|
||||
"""True when this asset may not report on the shared key.
|
||||
|
||||
An active credential always denies it: the PC has its own secret and a
|
||||
shared-key call for it is either an older agent or somebody else.
|
||||
|
||||
A revoked credential denies it only while revokeddeniessharedkey is on.
|
||||
Today it does not, on purpose: revoke is also how an admin rotates a
|
||||
credential or rolls a PC back to an agent that only knows the shared key
|
||||
(revoke + blockreenroll), and denying by default would silence that PC
|
||||
instead. A PC that never enrolled has no credential row and is never denied,
|
||||
which is what keeps the un-migrated fleet reporting.
|
||||
"""
|
||||
from ..models import EndpointCredential
|
||||
|
||||
if assetid is None:
|
||||
return False
|
||||
row = (EndpointCredential.query
|
||||
.filter(EndpointCredential.assetid == assetid)
|
||||
.first())
|
||||
if row is None:
|
||||
return False
|
||||
if row.revokedat is None:
|
||||
return True
|
||||
return revoked_denies_shared_key()
|
||||
|
||||
|
||||
def reenroll_blocked(assetid):
|
||||
"""True when an admin blocked this asset from enrolling again."""
|
||||
from ..models import EndpointCredential
|
||||
@@ -130,8 +175,7 @@ def shared_key_refused_for(hostname):
|
||||
"""True when the shared key may not report for this hostname."""
|
||||
if not shared_key_allowed():
|
||||
return True
|
||||
assetid = resolve_hostname(hostname)
|
||||
return assetid is not None and active_credential_for(assetid) is not None
|
||||
return shared_key_denied_for(resolve_hostname(hostname))
|
||||
|
||||
|
||||
def credential_invalid_response():
|
||||
@@ -148,6 +192,20 @@ def shared_key_refused_response():
|
||||
http_code=401)
|
||||
|
||||
|
||||
def shared_key_revoked_response():
|
||||
"""Same code and status; the operator needs to know which refusal it is.
|
||||
|
||||
The PC holds no credential and cannot use the shared key either, so an
|
||||
admin has to let it enroll again. Nothing changes for the agent, which
|
||||
keeps retrying either way.
|
||||
"""
|
||||
return error_response(
|
||||
CODE_SHARED_KEY_REFUSED,
|
||||
'This endpoint had its credential revoked, so the shared key is not '
|
||||
'accepted for it; let it enroll again',
|
||||
http_code=401)
|
||||
|
||||
|
||||
def asset_mismatch_response():
|
||||
return error_response(
|
||||
CODE_ASSET_MISMATCH,
|
||||
@@ -210,7 +268,8 @@ def bind_hostname(hostname):
|
||||
ASSET_MISMATCH, a hostname on no computer record at all is refused with the
|
||||
same code but its own message, and a missing hostname is fine.
|
||||
Shared key: the hostname's asset (None when absent or unknown, for the
|
||||
route to answer); an enrolled PC is refused.
|
||||
route to answer); an enrolled PC is refused, and so is one whose credential
|
||||
was revoked while revokeddeniessharedkey is on.
|
||||
"""
|
||||
hostname = (hostname or '').strip()
|
||||
if is_credential_caller():
|
||||
@@ -225,7 +284,9 @@ def bind_hostname(hostname):
|
||||
if not hostname:
|
||||
return None, None
|
||||
assetid = resolve_hostname(hostname)
|
||||
if assetid is not None and active_credential_for(assetid) is not None:
|
||||
if shared_key_denied_for(assetid):
|
||||
if active_credential_for(assetid) is None:
|
||||
return None, shared_key_revoked_response()
|
||||
return None, shared_key_refused_response()
|
||||
g.agentassetid = assetid
|
||||
return assetid, None
|
||||
|
||||
@@ -14,14 +14,25 @@ Routes
|
||||
POST /siteagent/bootstrap/tokens admin, audited; secret once
|
||||
GET /siteagent/bootstrap/tokens admin; never a secret
|
||||
POST /siteagent/bootstrap/tokens/<id>/revoke admin, audited
|
||||
GET /siteagent/bootstrap.ps1?t=... token only; not a use
|
||||
GET /siteagent/bootstrap/package?t=... token only; one use
|
||||
GET /siteagent/bootstrap.ps1 token only; not a use
|
||||
GET /siteagent/bootstrap/package token only; one use
|
||||
|
||||
The two token-authenticated routes answer 404 for anything not currently
|
||||
usable - unknown, expired, revoked or exhausted - so the URL confirms nothing
|
||||
about which tokens exist. Both are per-IP rate limited with the same fixed
|
||||
window shape the login and payload-download limiters use, and both write an
|
||||
audit row carrying the caller's IP.
|
||||
The two token-authenticated routes take the token in the
|
||||
X-Endpoint-Bootstrap-Token header, and still accept the deprecated ?t= form so
|
||||
a command already pasted on a bay keeps working. They answer 404 for anything
|
||||
not currently usable - unknown, malformed, expired, revoked, exhausted, or no
|
||||
token presented at all - so the URL confirms nothing about which tokens exist,
|
||||
nor about whether the query form is still accepted here. The one 400 is a real
|
||||
query token offered at a site that has turned the query form off.
|
||||
|
||||
The admin UI's Download .ps1 link has to use the query form (a browser link
|
||||
cannot send a header), so its URL carries a marker that tells it apart from a
|
||||
bay still running a command pasted before this change. Only the bay logs the
|
||||
deprecation warning, which is what the rollout counts un-migrated bays with.
|
||||
|
||||
Both routes are per-IP rate limited with the same fixed window shape the login
|
||||
and payload-download limiters use, and both write an audit row carrying the
|
||||
caller's IP.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -60,12 +71,60 @@ LIST_LIMIT = 100
|
||||
SCRIPT_PATH = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||
PACKAGE_PATH = '/api/endpoints/siteagent/bootstrap/package'
|
||||
|
||||
# Where the one-line command drops the script before running it. A literal, not
|
||||
# $env:TEMP: the line has to survive being pasted into an already-running
|
||||
# Where the one-line command stages the script before running it. A literal,
|
||||
# not $env:TEMP: the line has to survive being pasted into an already-running
|
||||
# PowerShell, which expands every $ inside the outer double quotes before the
|
||||
# child process sees them. The install needs elevation anyway, so this path is
|
||||
# writable by definition.
|
||||
COMMAND_SCRIPT_PATH = r'C:\Windows\Temp\shopdb-bootstrap.ps1'
|
||||
# child process sees them.
|
||||
#
|
||||
# NOT C:\Windows\Temp. That directory grants Users create-file and gives
|
||||
# CREATOR OWNER full control of what they create, so any standard user on the
|
||||
# bay can pre-plant this exact path and wait for a tech to paste the command,
|
||||
# which then runs the planted content elevated. Program Files is admin-only on
|
||||
# a stock install, the command re-asserts an admin-only DACL on the directory
|
||||
# it creates, and it refuses to run a staged file owned by anyone else.
|
||||
COMMAND_STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
|
||||
COMMAND_SCRIPT_PATH = COMMAND_STAGE_DIR + r'\shopdb-bootstrap.ps1'
|
||||
|
||||
# Well-known SIDs, not names: the bays are domain-joined and a renamed or
|
||||
# localized Administrators group would quietly break both the grant and the
|
||||
# ownership test.
|
||||
ADMINISTRATORS_SID = 'S-1-5-32-544'
|
||||
SYSTEM_SID = 'S-1-5-18'
|
||||
|
||||
# The token the two public routes accept. A header, not ?t=: a query string
|
||||
# lands verbatim in the reverse proxy and gunicorn access logs, and a live
|
||||
# install token has no business sitting in a log file.
|
||||
BOOTSTRAP_TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
|
||||
QUERY_TOKEN_ARG = 't'
|
||||
|
||||
# Where the token on this request came from. Kept apart because the rollout
|
||||
# counts un-migrated bays by the deprecation warning SOURCE_QUERY logs: an
|
||||
# admin clicking Download .ps1 in the browser must not land in that count, and
|
||||
# a request carrying no token at all must not answer differently from one
|
||||
# carrying a token nobody issued.
|
||||
SOURCE_HEADER = 'header'
|
||||
SOURCE_QUERY = 'query'
|
||||
SOURCE_UI_LINK = 'uilink'
|
||||
SOURCE_NONE = 'none'
|
||||
|
||||
# The marker on the admin Download .ps1 link. A hint, NOT authentication: a
|
||||
# caller can add it by hand, and nothing is granted by it. What matters is the
|
||||
# other direction - a one-line command generated before this change cannot
|
||||
# carry it, so a request without it really is a bay to go and re-issue.
|
||||
UI_LINK_ARG = 'via'
|
||||
UI_LINK_VALUE = 'download'
|
||||
|
||||
# DEPRECATED, kept on purpose: every command already pasted on a bay, and the
|
||||
# UI's "Download .ps1" link (a browser link cannot send a header), carry the
|
||||
# token in the query string. Accepting it stays the default so a mid-rollout
|
||||
# bay keeps working; a site that has re-issued its live commands turns it off.
|
||||
# Turning it off also disables the download link.
|
||||
#
|
||||
# Declared in the endpoints plugin's get_settings_defaults() (so the row seeds
|
||||
# boolean, not string) and flipped from Settings > Endpoints > Build site agent
|
||||
# > One-line install, "Accept the token in the URL". A switch with no seed and
|
||||
# no control would be inert on an air-gapped box.
|
||||
QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
|
||||
# Fixed-window per-IP limiter, same shape and cache extension as the login
|
||||
# limiter in shopdb.core.api.auth and the GE-Enforce payload limiter. A tech
|
||||
@@ -86,6 +145,22 @@ def _naive_utcnow():
|
||||
|
||||
|
||||
def _caller_ip():
|
||||
"""The caller address for the audit rows and the deprecation warning.
|
||||
|
||||
Read the value before trusting it to identify a bay. client_ip() returns
|
||||
remote_addr and nothing else. On the shipped Windows deploy IIS launches
|
||||
waitress on loopback through HttpPlatformHandler, and deploy/windows/
|
||||
web.config ships the X-Forwarded-For rewrite rule COMMENTED OUT - IIS sets
|
||||
no such header on its own, so waitress's --trusted-proxy=127.0.0.1
|
||||
--trusted-proxy-count=1 has nothing to rewrite REMOTE_ADDR from and leaves
|
||||
it as the loopback peer. TRUSTED_PROXY_COUNT also defaults to 0, so
|
||||
ProxyFix is not installed either. Every row and every warning then reads
|
||||
127.0.0.1, and the un-migrated-bay list identifies no bay.
|
||||
|
||||
It is a real address only where the site enabled that rewrite rule (the
|
||||
installer's -ClientIpSource direct), or set TRUSTED_PROXY_COUNT for a
|
||||
front proxy that sets the header itself.
|
||||
"""
|
||||
return client_ip(request) or 'unknown'
|
||||
|
||||
|
||||
@@ -142,11 +217,31 @@ def bootstrap_base_url():
|
||||
return 'https://{0}{1}'.format(host, root)
|
||||
|
||||
|
||||
def _base(baseurl=None):
|
||||
resolved = baseurl if baseurl is not None else bootstrap_base_url()
|
||||
return resolved.rstrip('/')
|
||||
|
||||
|
||||
def bootstrap_script_url(secret, baseurl=None):
|
||||
"""The token URL that serves the script: downloaded by the command, and
|
||||
offered in the UI as a plain file a tech can save and run instead."""
|
||||
base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/')
|
||||
return '{0}{1}?t={2}'.format(base, SCRIPT_PATH, secret)
|
||||
"""The UI's "Download .ps1" link: a plain file a tech can save and run.
|
||||
|
||||
Still the query-string form, because an <a download> cannot set a header.
|
||||
The one-line command no longer uses this shape - see bootstrap_command.
|
||||
|
||||
It carries UI_LINK_ARG so the server can tell this click apart from a bay
|
||||
running a pre-change command. Without that the admin's own Download link
|
||||
fires the deprecation warning the rollout uses to FIND un-migrated bays,
|
||||
and the count becomes noise the moment an admin mints a token.
|
||||
"""
|
||||
return '{0}{1}?{2}={3}&{4}={5}'.format(_base(baseurl), SCRIPT_PATH,
|
||||
QUERY_TOKEN_ARG, secret,
|
||||
UI_LINK_ARG, UI_LINK_VALUE)
|
||||
|
||||
|
||||
def bootstrap_fetch_url(path, baseurl=None):
|
||||
"""A token-free URL for the machine-to-machine fetches. The token rides in
|
||||
BOOTSTRAP_TOKEN_HEADER so it never reaches an access log."""
|
||||
return '{0}{1}'.format(_base(baseurl), path)
|
||||
|
||||
|
||||
def bootstrap_command(secret, baseurl=None):
|
||||
@@ -158,28 +253,145 @@ def bootstrap_command(secret, baseurl=None):
|
||||
|
||||
Single quotes inside and no $ anywhere, so the one line means the same
|
||||
whether it is pasted into cmd.exe or into a PowerShell prompt. The download
|
||||
and the run are wrapped in try/finally so the dropped file goes away even
|
||||
and the run are wrapped in try/finally so the staged file goes away even
|
||||
when the script exits non-zero or the download itself fails.
|
||||
|
||||
Staging is the part worth being careful about, so the line creates
|
||||
COMMAND_STAGE_DIR with an admin-only DACL, clears any leftover file, and
|
||||
refuses (exit 5) to run a staged script that some other principal owns -
|
||||
which is what a pre-planted file looks like. Well-known SIDs throughout,
|
||||
and the owner may also be the elevated caller, since a file created by an
|
||||
administrator is owned by that account rather than by the group.
|
||||
|
||||
The hardening is also CHECKED (exit 6). icacls is a native command, so it
|
||||
reports failure only in an exit code: no exception is raised, and
|
||||
$ErrorActionPreference never applies to it. Unchecked, an icacls that
|
||||
cannot write the DACL leaves the directory inheriting whatever the parent
|
||||
grants and the line downloads into it anyway - the silent version of the
|
||||
hole this staging move was made to close. The same check also covers the
|
||||
New-Item before it, because icacls on a directory that was never created
|
||||
exits non-zero too.
|
||||
|
||||
Read as `Get-Variable LASTEXITCODE -ValueOnly`, not `$LASTEXITCODE`: the
|
||||
whole line must stay free of `$`, or a paste into an already-running
|
||||
PowerShell expands it in the PARENT and the child is handed `-ne 0` with
|
||||
the parent's last exit code baked in.
|
||||
"""
|
||||
return ('powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ Invoke-WebRequest -UseBasicParsing '{0}' -OutFile '{1}'; "
|
||||
"& '{1}' }} finally {{ Remove-Item '{1}' -Force "
|
||||
'-ErrorAction SilentlyContinue }}"'
|
||||
).format(bootstrap_script_url(secret, baseurl),
|
||||
COMMAND_SCRIPT_PATH)
|
||||
return (
|
||||
'powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ New-Item -ItemType Directory -Force -Path '{stagedir}' "
|
||||
'| Out-Null; '
|
||||
"icacls '{stagedir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
|
||||
"'*{system}:(OI)(CI)F' | Out-Null; "
|
||||
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
|
||||
"{{ Write-Host 'Refusing to run: could not restrict "
|
||||
"{stagedir} to administrators (icacls failed). Fix the directory "
|
||||
"or delete it, then run the command again.' -ForegroundColor Red; "
|
||||
'exit 6 }}; '
|
||||
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
|
||||
"Invoke-WebRequest -UseBasicParsing '{url}' "
|
||||
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
|
||||
"if ((Get-Acl '{path}').GetOwner("
|
||||
'[Security.Principal.SecurityIdentifier]).Value -notin '
|
||||
"@('{admin}','{system}',"
|
||||
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
|
||||
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
|
||||
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
|
||||
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
|
||||
'-ErrorAction SilentlyContinue }}"'
|
||||
).format(stagedir=COMMAND_STAGE_DIR,
|
||||
path=COMMAND_SCRIPT_PATH,
|
||||
url=bootstrap_fetch_url(SCRIPT_PATH, baseurl),
|
||||
header=BOOTSTRAP_TOKEN_HEADER,
|
||||
secret=secret,
|
||||
admin=ADMINISTRATORS_SID,
|
||||
system=SYSTEM_SID)
|
||||
|
||||
|
||||
def _hash_token(secret):
|
||||
return hashlib.sha256(secret.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _resolve_token():
|
||||
"""The usable token named by ?t=, else None.
|
||||
def _presented_token():
|
||||
"""(secret, source) for the token this request carries.
|
||||
|
||||
Header first, query string second. The query form is deprecated but still
|
||||
the only one a browser download link can use.
|
||||
|
||||
SOURCE_NONE when nothing was presented, which is NOT the same as an empty
|
||||
query token: reporting a bare GET as 'query' made it answer 400 naming the
|
||||
header, telling an anonymous caller whether this site had turned the query
|
||||
form off, and contradicting the 404-for-everything-unusable rule at the top
|
||||
of this module.
|
||||
"""
|
||||
header = (request.headers.get(BOOTSTRAP_TOKEN_HEADER) or '').strip()
|
||||
if header:
|
||||
return header, SOURCE_HEADER
|
||||
query = (request.args.get(QUERY_TOKEN_ARG) or '').strip()
|
||||
if not query:
|
||||
return '', SOURCE_NONE
|
||||
if request.args.get(UI_LINK_ARG) == UI_LINK_VALUE:
|
||||
return query, SOURCE_UI_LINK
|
||||
return query, SOURCE_QUERY
|
||||
|
||||
|
||||
def _is_query_source(source):
|
||||
"""Both query-string forms, the bay's and the admin download link's. The
|
||||
setting is about a token in an access log, and the admin link puts one
|
||||
there just as surely as a pasted command does."""
|
||||
return source in (SOURCE_QUERY, SOURCE_UI_LINK)
|
||||
|
||||
|
||||
def _query_token_allowed():
|
||||
"""Defaults to True: today's pasted commands and the UI download link both
|
||||
use the query form, and turning that off mid-rollout strands a bay."""
|
||||
return bool(Setting.get(QUERY_TOKEN_SETTING, True))
|
||||
|
||||
|
||||
def _query_token_refused():
|
||||
"""Answered before any lookup, so it confirms nothing about any token."""
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Send the bootstrap token in the {0} header.'.format(
|
||||
BOOTSTRAP_TOKEN_HEADER),
|
||||
http_code=400)
|
||||
|
||||
|
||||
def _note_token_source(source):
|
||||
"""Audit suffix for a token that really resolved, and, for a query-string
|
||||
token, one log line naming the caller and WHICH query path it was.
|
||||
|
||||
Only the bay warns. That warning is the rollout's un-migrated-bay counter
|
||||
(see PER-PC-CREDENTIALS-ROLLOUT.md), so the admin's own Download .ps1 link
|
||||
logs at info instead - otherwise every mint-and-test by an admin adds a
|
||||
bay that does not exist.
|
||||
|
||||
Never the token itself, in either line: keeping it out of the logs is the
|
||||
whole point of the header form.
|
||||
"""
|
||||
if source == SOURCE_UI_LINK:
|
||||
current_app.logger.info(
|
||||
'bootstrap script fetched by %s from the admin Download .ps1 '
|
||||
'link; query-string token, but the admin browser path, NOT an '
|
||||
'un-migrated bay', _caller_ip())
|
||||
return ' (query-string token, admin download link)'
|
||||
if source == SOURCE_QUERY:
|
||||
current_app.logger.warning(
|
||||
'bootstrap token presented in the query string by %s with no '
|
||||
'admin download marker, so this is an un-migrated bay running a '
|
||||
'command pasted before the header form; that form is deprecated '
|
||||
'and slated for removal, re-issue the one-line install command',
|
||||
_caller_ip())
|
||||
return ' (query-string token, deprecated)'
|
||||
return ''
|
||||
|
||||
|
||||
def _resolve_token(secret):
|
||||
"""The usable token with this secret, else None.
|
||||
|
||||
Unknown, malformed, expired, revoked and exhausted all resolve to None so
|
||||
the caller can answer 404 for every one of them.
|
||||
"""
|
||||
secret = (request.args.get('t') or '').strip()
|
||||
if not _TOKEN_PATTERN.match(secret):
|
||||
return None
|
||||
row = EndpointBootstrapToken.query.filter_by(
|
||||
@@ -218,6 +430,7 @@ _SCRIPT_TEMPLATE = '''# ShopDB endpoint agent bootstrap
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BootstrapPackageUrl = {packageurl}
|
||||
$BootstrapPackageHeaders = @{{ {tokenheader} = {token} }}
|
||||
$BootstrapServiceName = "ShopDbEndpointAgent"
|
||||
$BootstrapInstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent"
|
||||
$BootstrapAgentSha256 = {agentsha}
|
||||
@@ -308,7 +521,7 @@ try {{
|
||||
New-Item -ItemType Directory -Force -Path $bootstrapTempDir | Out-Null
|
||||
|
||||
Write-Host "[1/4] Downloading the agent package ..."
|
||||
Invoke-WebRequest -Uri $BootstrapPackageUrl -OutFile $bootstrapZipPath -UseBasicParsing
|
||||
Invoke-WebRequest -Uri $BootstrapPackageUrl -Headers $BootstrapPackageHeaders -OutFile $bootstrapZipPath -UseBasicParsing
|
||||
|
||||
Write-Host "[2/4] Expanding and verifying ..."
|
||||
Expand-Archive -Path $bootstrapZipPath -DestinationPath $bootstrapExtractDir -Force
|
||||
@@ -394,11 +607,12 @@ exit 0
|
||||
|
||||
def build_bootstrap_script(secret, baseurl=None):
|
||||
"""The PowerShell text served for one token, with the URL and hashes baked in."""
|
||||
base = (baseurl if baseurl is not None else bootstrap_base_url()).rstrip('/')
|
||||
base = _base(baseurl)
|
||||
hashes = siteagent.staged_binary_hashes()
|
||||
return _SCRIPT_TEMPLATE.format(
|
||||
packageurl=json.dumps('{0}{1}?t={2}'.format(base, PACKAGE_PATH,
|
||||
secret)),
|
||||
packageurl=json.dumps(bootstrap_fetch_url(PACKAGE_PATH, base)),
|
||||
tokenheader=json.dumps(BOOTSTRAP_TOKEN_HEADER),
|
||||
token=json.dumps(secret),
|
||||
agentsha=json.dumps(hashes.get('agent', '')),
|
||||
traysha=json.dumps(hashes.get('tray', '')),
|
||||
forcevariable=FORCE_VARIABLE,
|
||||
@@ -500,7 +714,8 @@ def revoke_bootstrap_token(tokenid):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-authenticated: no login, the token in ?t= is the whole authorization
|
||||
# Token-authenticated: no login, the token is the whole authorization. It
|
||||
# rides in the X-Endpoint-Bootstrap-Token header; ?t= is still accepted.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@endpoints_bp.route('/siteagent/bootstrap.ps1', methods=['GET'])
|
||||
@@ -516,12 +731,15 @@ def serve_bootstrap_script():
|
||||
"""
|
||||
if _ratelimited():
|
||||
return _rate_limit_response()
|
||||
row = _resolve_token()
|
||||
secret, source = _presented_token()
|
||||
if _is_query_source(source) and not _query_token_allowed():
|
||||
return _query_token_refused()
|
||||
row = _resolve_token(secret)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
|
||||
secret = (request.args.get('t') or '').strip()
|
||||
_audit('requested', row, 'bootstrap script served')
|
||||
_audit('requested', row,
|
||||
'bootstrap script served' + _note_token_source(source))
|
||||
db.session.commit()
|
||||
|
||||
return Response(build_bootstrap_script(secret), mimetype='text/plain')
|
||||
@@ -536,7 +754,10 @@ def download_bootstrap_package():
|
||||
"""
|
||||
if _ratelimited():
|
||||
return _rate_limit_response()
|
||||
row = _resolve_token()
|
||||
secret, source = _presented_token()
|
||||
if _is_query_source(source) and not _query_token_allowed():
|
||||
return _query_token_refused()
|
||||
row = _resolve_token(secret)
|
||||
if row is None:
|
||||
return _not_found()
|
||||
|
||||
@@ -544,8 +765,8 @@ def download_bootstrap_package():
|
||||
row.usecount = (row.usecount or 0) + 1
|
||||
row.lastusedat = now
|
||||
row.lastusedip = _caller_ip()[:45]
|
||||
_audit('downloaded', row, 'bootstrap package, use {0} of {1}'.format(
|
||||
row.usecount, row.maxuses))
|
||||
_audit('downloaded', row, 'bootstrap package, use {0} of {1}{2}'.format(
|
||||
row.usecount, row.maxuses, _note_token_source(source)))
|
||||
db.session.commit()
|
||||
|
||||
buffer, warning = siteagent.build_keyless_package(bootstrap_base_url())
|
||||
|
||||
@@ -78,6 +78,14 @@ ENROLL_RATELIMIT_WINDOW_SECONDS = 60
|
||||
PENDING_CAP_SETTING = 'plugin.endpoints.maxpendingenrollments'
|
||||
DEFAULT_PENDING_CAP = 500
|
||||
|
||||
# How long a pending request may go untouched before the cap treats it as
|
||||
# abandoned. An agent waiting for approval re-asks about once a minute, so a row
|
||||
# nothing has touched for a week is a PC that was re-imaged, renamed or scrapped,
|
||||
# or a junk row somebody wrote at the open door. At the cap those are dropped to
|
||||
# make room before a PC nobody has seen before is refused, so a queue that filled
|
||||
# with abandoned rows drains itself instead of blocking every new PC on site.
|
||||
PENDING_STALE_AFTER = timedelta(days=7)
|
||||
|
||||
_NONCE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{32,128}$')
|
||||
|
||||
REASON_UNKNOWN_HOSTNAME = 'unknown hostname'
|
||||
@@ -265,6 +273,38 @@ def _pending_queue_is_full():
|
||||
return pending >= cap
|
||||
|
||||
|
||||
def _drop_stale_pending():
|
||||
"""Delete pending requests nothing has touched in PENDING_STALE_AFTER.
|
||||
|
||||
Only PENDING rows: an admin decision (approved, rejected, issued) is never
|
||||
aged out, so a rejection keeps standing however long the PC argues with it.
|
||||
Returns how many rows went, for the caller to decide whether the cap is
|
||||
still reached.
|
||||
"""
|
||||
cutoff = _naive_utcnow() - PENDING_STALE_AFTER
|
||||
stale = (EndpointEnrollRequest.query
|
||||
.filter(EndpointEnrollRequest.status
|
||||
== EndpointEnrollRequest.STATUS_PENDING,
|
||||
EndpointEnrollRequest.lastseenat < cutoff)
|
||||
.order_by(EndpointEnrollRequest.lastseenat.asc())
|
||||
.all())
|
||||
if not stale:
|
||||
return 0
|
||||
for row in stale:
|
||||
db.session.delete(row)
|
||||
current_app.logger.info(
|
||||
'endpoint enroll: aged out %d pending requests untouched for %d days',
|
||||
len(stale), PENDING_STALE_AFTER.days)
|
||||
AuditLog.log(
|
||||
'deleted', 'EndpointEnrollRequest',
|
||||
entityname=stale[0].hostname,
|
||||
details='aged out {0} pending enroll requests untouched for {1} '
|
||||
'days, to make room under the pending cap'.format(
|
||||
len(stale), PENDING_STALE_AFTER.days))
|
||||
db.session.commit()
|
||||
return len(stale)
|
||||
|
||||
|
||||
def _pending_queue_full_response(hostname, serialnumber):
|
||||
"""Refuse a new row, say so plainly in the log and leave an audit trail."""
|
||||
cap = pending_cap()
|
||||
@@ -349,15 +389,42 @@ def enroll_endpoint():
|
||||
|
||||
if row is None:
|
||||
# First contact for this hostname and serial. This is the only branch
|
||||
# that grows the table, so it is the only one the cap guards.
|
||||
# that grows the table, so it is the only one the cap guards. Every
|
||||
# other contact reuses the one row this identity already owns, which is
|
||||
# what keeps a single PC from holding a queue's worth of them.
|
||||
if _pending_queue_is_full():
|
||||
return _pending_queue_full_response(hostname, serialnumber)
|
||||
# Abandoned rows come out before a real PC is turned away.
|
||||
_drop_stale_pending()
|
||||
if _pending_queue_is_full():
|
||||
return _pending_queue_full_response(hostname, serialnumber)
|
||||
row = EndpointEnrollRequest(
|
||||
hostname=hostname, serialnumber=serialnumber, enrollnonce=nonce,
|
||||
status=EndpointEnrollRequest.STATUS_PENDING, requestedat=now)
|
||||
db.session.add(row)
|
||||
else:
|
||||
samenonce = hmac.compare_digest(row.enrollnonce, nonce)
|
||||
if row.status == EndpointEnrollRequest.STATUS_REJECTED:
|
||||
# A rejection binds to the IDENTITY - this hostname and serial - not
|
||||
# to the nonce it was rejected with. A new installation on the same
|
||||
# PC brings a new nonce, and re-arming the row on it would put the
|
||||
# request back in the queue and, with the shared key and auto-enroll
|
||||
# on, hand the retry the credential an admin had just refused.
|
||||
# Nothing the PC sends clears a rejection; an admin approving the
|
||||
# request does.
|
||||
#
|
||||
# The nonce is still recorded, because a rejected row hands nothing
|
||||
# out and an admin who does approve it later has to bind that
|
||||
# approval to the installation asking NOW, not to the one that was
|
||||
# refused weeks ago.
|
||||
row.enrollnonce = nonce
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
if agentversion:
|
||||
row.agentversion = agentversion
|
||||
db.session.commit()
|
||||
return success_response(
|
||||
{'status': 'pending', 'requestid': row.requestid,
|
||||
'reason': REASON_REJECTED}, http_code=202)
|
||||
if row.status == EndpointEnrollRequest.STATUS_APPROVED:
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
@@ -380,16 +447,10 @@ def enroll_endpoint():
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
return _pending(row, REASON_NONCE_CHANGED, row.matchedassetid)
|
||||
if row.status == EndpointEnrollRequest.STATUS_REJECTED and samenonce:
|
||||
row.lastseenat = now
|
||||
row.sourceip = sourceip
|
||||
db.session.commit()
|
||||
return success_response(
|
||||
{'status': 'pending', 'requestid': row.requestid,
|
||||
'reason': REASON_REJECTED}, http_code=202)
|
||||
if row.status != EndpointEnrollRequest.STATUS_PENDING:
|
||||
# Issued earlier (credential since revoked) or rejected for another
|
||||
# installation: a new enrollment cycle bound to this nonce.
|
||||
# Issued earlier and the credential has since been revoked: a new
|
||||
# enrollment cycle bound to this nonce. Rejected rows never reach
|
||||
# here - they returned above, whatever nonce they carried.
|
||||
row.enrollnonce = nonce
|
||||
row.requestedat = now
|
||||
row.decidedat = None
|
||||
@@ -574,6 +635,7 @@ def _page(rows):
|
||||
def _settings_payload():
|
||||
return {'sharedkeyallowed': agentauth.shared_key_allowed(),
|
||||
'autoenroll': agentauth.auto_enroll_enabled(),
|
||||
'revokeddeniessharedkey': agentauth.revoked_denies_shared_key(),
|
||||
'maxpendingenrollments': pending_cap()}
|
||||
|
||||
|
||||
@@ -667,6 +729,11 @@ def approve_enroll_request(requestid):
|
||||
The pending reason for a one-off PC is by definition `unknown hostname`, so
|
||||
this is the normal case, not the exception.
|
||||
|
||||
Approving is also the only way out of a rejection: a rejected request keeps
|
||||
refusing its PC whatever nonce it sends, and this is the deliberate admin
|
||||
step that lifts it. The approval binds to the nonce of that PC's most recent
|
||||
contact, so an agent reinstalled while it was refused still collects it.
|
||||
|
||||
Body: {"assetid": 123, "sethostname": false}. A computer with no hostname
|
||||
is stamped with the request's. A computer carrying a DIFFERENT hostname is
|
||||
refused with 409 naming both, until the caller repeats the approval with
|
||||
@@ -734,6 +801,7 @@ def approve_enroll_request(requestid):
|
||||
details='hostname {0} from enroll request {1}'.format(
|
||||
hostnameresult, row.requestid))
|
||||
|
||||
wasrejected = row.status == EndpointEnrollRequest.STATUS_REJECTED
|
||||
row.status = EndpointEnrollRequest.STATUS_APPROVED
|
||||
row.matchedassetid = assetid
|
||||
row.reason = None
|
||||
@@ -741,8 +809,9 @@ def approve_enroll_request(requestid):
|
||||
row.decidedby = _username()
|
||||
AuditLog.log('updated', 'EndpointEnrollRequest', entityid=row.requestid,
|
||||
entityname=row.hostname,
|
||||
details='enroll request approved for asset {0}, hostname {1}'
|
||||
.format(assetid, hostnameresult))
|
||||
details='enroll request {0} for asset {1}, hostname {2}'
|
||||
.format('approved out of a rejection' if wasrejected
|
||||
else 'approved', assetid, hostnameresult))
|
||||
db.session.commit()
|
||||
approved = row.to_dict()
|
||||
approved['hostnameresult'] = hostnameresult
|
||||
@@ -780,6 +849,11 @@ def revoke_endpoint_credential(assetid):
|
||||
before rolling a PC back to an agent that only knows the shared key: without
|
||||
it the current agent re-enrolls within seconds and the older agent is then
|
||||
refused. A PC whose credential is already revoked can still be blocked.
|
||||
|
||||
Whether a revoked PC may still report on the shared key is the site setting
|
||||
plugin.endpoints.revokeddeniessharedkey. It is off here, so today it can -
|
||||
which is what makes the rollback above work, and what keeps a rotation from
|
||||
silencing the PC while it waits for approval.
|
||||
"""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not isinstance(payload, dict):
|
||||
@@ -853,12 +927,24 @@ def allow_endpoint_reenroll(assetid):
|
||||
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
|
||||
@require_role('admin')
|
||||
def update_enrollment_settings():
|
||||
"""Change the enrollment settings.
|
||||
|
||||
revokeddeniessharedkey is off by default, which is what the fleet does
|
||||
today: a PC whose credential was revoked falls back to the shared key, so a
|
||||
credential rotation, and the rollback to an older agent that revoke +
|
||||
blockreenroll exists for, both keep reporting. Turning it on makes a revoke
|
||||
an eviction - that PC reports nothing until it is allowed to enroll again -
|
||||
and is only safe once every PC here holds its own credential.
|
||||
"""
|
||||
from shopdb.api import Setting
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
changes = {}
|
||||
for key, settingkey in (('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
|
||||
('autoenroll', agentauth.AUTO_ENROLL_SETTING)):
|
||||
for key, settingkey in (
|
||||
('sharedkeyallowed', agentauth.SHARED_KEY_SETTING),
|
||||
('autoenroll', agentauth.AUTO_ENROLL_SETTING),
|
||||
('revokeddeniessharedkey',
|
||||
agentauth.REVOKED_DENIES_SHARED_KEY_SETTING)):
|
||||
if key not in payload:
|
||||
continue
|
||||
if not isinstance(payload[key], bool):
|
||||
@@ -878,7 +964,8 @@ def update_enrollment_settings():
|
||||
if not changes:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Send sharedkeyallowed, autoenroll and/or maxpendingenrollments')
|
||||
'Send sharedkeyallowed, autoenroll, revokeddeniessharedkey '
|
||||
'and/or maxpendingenrollments')
|
||||
|
||||
before = _settings_payload()
|
||||
for key, (settingkey, value, valuetype) in changes.items():
|
||||
|
||||
@@ -410,6 +410,57 @@ function Set-HklmValueBothViews {{
|
||||
}}
|
||||
}}
|
||||
|
||||
# Lock SOFTWARE\\GE\\ShopDB to SYSTEM and Administrators, in both views.
|
||||
#
|
||||
# WHY. The shared fleet key is written here and stays until every consumer is
|
||||
# on a per-PC credential, and the DACL this key inherits by default grants
|
||||
# BUILTIN\\Users read - so any logged-on user on any bay could read the fleet
|
||||
# key, which is the exact exposure the per-PC credential migration exists to
|
||||
# close. Everything that legitimately reads this key runs as SYSTEM (the agent
|
||||
# service, the GE-Enforce reporters) or as an admin (the adoption tool,
|
||||
# Set-ShopDBServerPins.ps1). The tray reads no registry at all - it talks to the
|
||||
# service over the named pipe - so nothing loses access here.
|
||||
#
|
||||
# It also protects ServerPins from a standard user, which is what makes the
|
||||
# registry safe to treat as authoritative for pinning.
|
||||
function Protect-HklmShopdbKey {{
|
||||
param([Parameter(Mandatory)] [string] $SubKey)
|
||||
$rights = [System.Security.AccessControl.RegistryRights]
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
||||
[Microsoft.Win32.RegistryView]::Registry32)) {{
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {{
|
||||
$key = $base.OpenSubKey(
|
||||
$SubKey,
|
||||
[Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
|
||||
$rights::ChangePermissions -bor $rights::ReadPermissions)
|
||||
if (-not $key) {{ continue }}
|
||||
try {{
|
||||
$acl = $key.GetAccessControl(
|
||||
[System.Security.AccessControl.AccessControlSections]::Access)
|
||||
# Break inheritance and DROP the inherited entries rather than
|
||||
# copying them in: copying is what silently preserves the
|
||||
# BUILTIN\\Users read this function exists to remove.
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($rule in @($acl.Access)) {{ $null = $acl.RemoveAccessRule($rule) }}
|
||||
foreach ($sid in @("S-1-5-18", "S-1-5-32-544")) {{
|
||||
$who = New-Object System.Security.Principal.SecurityIdentifier($sid)
|
||||
$acl.AddAccessRule((New-Object System.Security.AccessControl.RegistryAccessRule(
|
||||
$who,
|
||||
$rights::FullControl,
|
||||
[System.Security.AccessControl.InheritanceFlags]::ContainerInherit,
|
||||
[System.Security.AccessControl.PropagationFlags]::None,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow)))
|
||||
}}
|
||||
$key.SetAccessControl($acl)
|
||||
}}
|
||||
finally {{ $key.Dispose() }}
|
||||
}}
|
||||
finally {{ $base.Dispose() }}
|
||||
}}
|
||||
}}
|
||||
|
||||
# REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same
|
||||
# pins leaves the value (and its last-write time) alone.
|
||||
function Set-HklmMultiStringBothViews {{
|
||||
@@ -492,6 +543,17 @@ else {{
|
||||
Write-Warning "no server pin in this package; ServerPins left as is."
|
||||
}}
|
||||
|
||||
# Harden the key AFTER the writes: the DACL is replaced, not merged, so writing
|
||||
# first and locking second keeps this correct whether or not the key pre-existed.
|
||||
try {{
|
||||
Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"
|
||||
Write-Host " HKLM\\SOFTWARE\\GE\\ShopDB restricted to SYSTEM and Administrators."
|
||||
}}
|
||||
catch {{
|
||||
Write-Warning "could not restrict HKLM\\SOFTWARE\\GE\\ShopDB: $($_.Exception.Message)"
|
||||
Write-Warning "the shared key on this PC stays readable by any logged-on user until that is fixed."
|
||||
}}
|
||||
|
||||
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
|
||||
# TLS to a self-signed or internal-CA server fails closed until the machine
|
||||
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
|
||||
|
||||
@@ -292,17 +292,63 @@ says so when it finds an agent already installed.
|
||||
a new one if it is lost.
|
||||
3. On the target PC, open PowerShell **as administrator** and paste it:
|
||||
|
||||
**Copy the command the UI gives you.** Do not retype it from here and do not
|
||||
reuse one you saved from an older round - see the warning under this block. It
|
||||
looks like this:
|
||||
|
||||
```
|
||||
powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing 'https://<server>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing 'https://<server>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
```
|
||||
|
||||
It downloads the script, runs it and deletes it again. Do not shorten it back
|
||||
to `irm ... | iex`: Defender scores that shape as `Trojan:Win32/Commando.A!ml`
|
||||
and kills the process before anything runs, on a default install with no ASR
|
||||
rules. Paste it as one line into an elevated **cmd** or **PowerShell** window;
|
||||
it means the same in both. **Download .ps1** in the same block saves the script
|
||||
instead, for a PC where pasting a long line is awkward - it carries the same
|
||||
token, so treat the file the way you treat the command.
|
||||
It creates an admin-only staging directory, downloads the script into it, runs
|
||||
it and deletes it again. Do not shorten it back to `irm ... | iex`: Defender
|
||||
scores that shape as `Trojan:Win32/Commando.A!ml` and kills the process before
|
||||
anything runs, on a default install with no ASR rules. Paste it as one line
|
||||
into an elevated **cmd** or **PowerShell** window; it means the same in both.
|
||||
**Download .ps1** in the same block saves the script instead, for a PC where
|
||||
pasting a long line is awkward - it carries the same token, so treat the file
|
||||
the way you treat the command.
|
||||
|
||||
> **Do not use the old `?t=<token>` one-liner that staged into
|
||||
> `C:\Windows\Temp`.** Any standard user on the bay can create that exact path
|
||||
> first and wait for a tech to paste the command, which then runs the planted
|
||||
> file elevated; the token also lands in the web server's access log. The
|
||||
> command above stages under `C:\Program Files`, locks the directory to
|
||||
> Administrators and SYSTEM and stops if that fails, refuses to run a staged
|
||||
> file owned by anyone else, and sends the token in a header.
|
||||
>
|
||||
> The `?t=` form is still ACCEPTED, but only so a bay provisioned before this
|
||||
> change keeps working until its command is re-issued. Every such fetch logs a
|
||||
> server warning and writes an audit row against the token:
|
||||
>
|
||||
> ```
|
||||
> bootstrap token presented in the query string by <caller> with no admin
|
||||
> download marker, so this is an un-migrated bay running a command pasted
|
||||
> before the header form; ... re-issue the one-line install command
|
||||
> ```
|
||||
>
|
||||
> **`<caller>` is 127.0.0.1 on a stock Windows install.** IIS runs the app
|
||||
> through HttpPlatformHandler over loopback and sets no `X-Forwarded-For` of
|
||||
> its own; `deploy/windows/web.config` ships the rewrite rule that would add
|
||||
> one commented out, so waitress has nothing to rewrite `REMOTE_ADDR` from.
|
||||
> Check before you rely on the address: if these lines all say 127.0.0.1, the
|
||||
> warning still tells you un-migrated bays EXIST and how many fetches they
|
||||
> made, but not which bays. Get real addresses by enabling that rule (the
|
||||
> Windows installer's `-ClientIpSource direct`, which installs URL Rewrite and
|
||||
> allows `HTTP_X_FORWARDED_FOR` at server level); behind a further reverse
|
||||
> proxy leave it off and set `TRUSTED_PROXY_COUNT` instead. Without that, the
|
||||
> usable identifier is the token: each warning has a matching audit row on
|
||||
> `EndpointBootstrapToken` carrying the 12-character `tokenprefix`, so you can
|
||||
> at least tell which install round the stale command came from.
|
||||
>
|
||||
> Work that list down: mint a new token and hand out the new command. Clicking
|
||||
> **Download .ps1** yourself does NOT add to it - that link is marked
|
||||
> `&via=download` and logs at info, precisely so the warnings stay a list of
|
||||
> bays. Once the list is empty, clear **Accept the token in the URL** in
|
||||
> Settings -> Endpoints -> Build site agent -> One-line install
|
||||
> (`plugin.endpoints.bootstrapquerytoken`) to refuse the query form outright.
|
||||
> That also disables the Download .ps1 link and kills every command still in a
|
||||
> technician's hands, so do it deliberately and re-issue first.
|
||||
|
||||
The token is short-lived and only fetches the installer. It carries no shared
|
||||
key and provisions nothing by itself: the package it downloads is built with
|
||||
@@ -324,6 +370,15 @@ window to override). After a successful install it waits up to two minutes for
|
||||
the credential to land and prints `approved and enrolled` if an admin approves
|
||||
it in that window; still pending is a normal outcome, not a failure.
|
||||
|
||||
If it stops before any of that with a red **Refusing to run**, nothing was
|
||||
installed and nothing was downloaded. Two cases, both meaning look at the bay
|
||||
rather than retry:
|
||||
|
||||
| Exit | Message | What to do |
|
||||
|---|---|---|
|
||||
| 6 | could not restrict `C:\Program Files\ShopDB\bootstrap` to administrators | the ACL could not be set - usually the directory already exists with an owner or DACL you did not put there. Inspect it (`icacls`), delete it, run the command again. |
|
||||
| 5 | the staged bootstrap script is not owned by an administrator | something else wrote the staged file. Treat the bay as suspect, do not force past it. |
|
||||
|
||||
Revoke a token from the same block when the install round is done. Tokens list
|
||||
with their prefix, expiry and uses; they are also spent automatically
|
||||
(`usecount` reaching `maxuses`) and expire on their own.
|
||||
|
||||
@@ -482,6 +482,31 @@
|
||||
Settings > Endpoints > Enrollment.
|
||||
</p>
|
||||
|
||||
<!-- The deprecated query form of the bootstrap token. Rendered here
|
||||
because this is the page that mints the command the setting
|
||||
governs: turning it off invalidates every command already handed
|
||||
out that carries ?t=. -->
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-querytoken"
|
||||
:checked="querytokenallowed"
|
||||
:disabled="querytokenbusy"
|
||||
@change="onQueryTokenToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>Accept the token in the URL.</strong> On by default:
|
||||
the pasted one-line command and the Download .ps1 link above both
|
||||
send the token as <code>?t=</code>, which is the only form a
|
||||
browser download link can use. The token lands in the IIS and
|
||||
proxy logs that way, so the header form
|
||||
(<code>X-Endpoint-Bootstrap-Token</code>) replaces it. Turn this off
|
||||
and every install command still in a technician's hands stops
|
||||
working immediately - re-issue them first.
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="querytokenerror" class="field-error">{{ querytokenerror }}</p>
|
||||
|
||||
<table v-if="bootstraptokens.length" class="versions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -764,6 +789,15 @@ const bootstraptokens = ref([])
|
||||
const bootstrapmessage = ref('')
|
||||
const bootstraperror = ref('')
|
||||
|
||||
// plugin.endpoints.bootstrapquerytoken, read and written through the core
|
||||
// settings API: the endpoints plugin has no settings route of its own for it,
|
||||
// and the key is declared in get_settings_defaults so the upsert on PUT gets
|
||||
// the boolean valuetype and the description from that declaration.
|
||||
const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
const querytokenallowed = ref(true)
|
||||
const querytokenbusy = ref(false)
|
||||
const querytokenerror = ref('')
|
||||
|
||||
function agentnextpininvalid() {
|
||||
const pin = agentnextserverpin.value
|
||||
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
||||
@@ -863,6 +897,7 @@ onMounted(async () => {
|
||||
}
|
||||
loadEnrollment()
|
||||
loadBootstrapTokens()
|
||||
loadQueryTokenSetting()
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent')
|
||||
const status = response.data.data || {}
|
||||
@@ -878,6 +913,53 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function loadQueryTokenSetting() {
|
||||
try {
|
||||
const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
|
||||
// The backend reads it as bool(Setting.get(key, True)), so only an
|
||||
// explicit false is off. A row seeded as boolean comes back as a real
|
||||
// false; an older row that was created as a string comes back as 'false'.
|
||||
const value = response.data.data?.value
|
||||
querytokenallowed.value = !(value === false || value === 'false')
|
||||
} catch (loadError) {
|
||||
// 404 = the row was never seeded (plugin installed before the key was
|
||||
// declared). The backend default is on, so show on.
|
||||
if (loadError.response?.status !== 404) console.error(loadError)
|
||||
querytokenallowed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onQueryTokenToggle(event) {
|
||||
const turningOn = event.target.checked
|
||||
if (!turningOn) {
|
||||
const answer = prompt(
|
||||
'Stop accepting the token in the URL?\n\n'
|
||||
+ 'Every install command already handed out uses ?t= and stops '
|
||||
+ 'working at once, including the Download .ps1 links. Re-issue them '
|
||||
+ 'before turning this off.\n\n'
|
||||
+ 'Type OFF to confirm.')
|
||||
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
||||
event.target.checked = true
|
||||
return
|
||||
}
|
||||
}
|
||||
querytokenbusy.value = true
|
||||
querytokenerror.value = ''
|
||||
try {
|
||||
// PUT upserts, so this works on a site whose row was never seeded.
|
||||
await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
|
||||
querytokenallowed.value = turningOn
|
||||
} catch (saveError) {
|
||||
querytokenerror.value = saveError.response?.status === 403
|
||||
? 'Only an administrator can change this setting'
|
||||
: 'Could not save the setting'
|
||||
event.target.checked = querytokenallowed.value
|
||||
console.error(saveError)
|
||||
} finally {
|
||||
querytokenbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBootstrapTokens() {
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
|
||||
|
||||
@@ -229,6 +229,27 @@
|
||||
on record matches. Anything else waits below for an admin.
|
||||
</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-revokeddenies"
|
||||
:checked="enrollmentSettings.revokeddeniessharedkey"
|
||||
:disabled="!isadmin || busy"
|
||||
@change="onRevokedDeniesToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>A revoked PC loses the shared key too.</strong> (setting
|
||||
<code>plugin.endpoints.revokeddeniessharedkey</code>) Off by
|
||||
default, which is what the fleet does today: a PC whose credential
|
||||
was revoked falls back to the shared key and keeps reporting, so a
|
||||
credential rotation does not strand it. Turn this on and a revoke
|
||||
becomes an eviction - that PC reports nothing and receives no
|
||||
commands until it enrolls again - and the documented rollback of
|
||||
revoke plus block re-enrollment onto an older agent that only knows
|
||||
the shared key stops working. Only safe once every PC here is
|
||||
enrolled.
|
||||
</span>
|
||||
</label>
|
||||
<label class="number-row">
|
||||
<span>
|
||||
<strong>Pending request limit.</strong> How many requests may wait
|
||||
@@ -670,6 +691,9 @@ const DEFAULT_PENDING_CAP = 500
|
||||
const enrollmentSettings = ref({
|
||||
sharedkeyallowed: true,
|
||||
autoenroll: true,
|
||||
// Matches the seeded default (plugin.endpoints.revokeddeniessharedkey is
|
||||
// false), so the box renders unchecked before the first read lands.
|
||||
revokeddeniessharedkey: false,
|
||||
maxpendingenrollments: DEFAULT_PENDING_CAP
|
||||
})
|
||||
// Bound with v-model rather than :value so the field holds what the operator
|
||||
@@ -697,6 +721,8 @@ async function loadEnrollment({ quiet = false } = {}) {
|
||||
enrollmentSettings.value = {
|
||||
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
|
||||
autoenroll: data.settings?.autoenroll !== false,
|
||||
// Opt-in, so anything but an explicit true reads as off.
|
||||
revokeddeniessharedkey: data.settings?.revokeddeniessharedkey === true,
|
||||
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
|
||||
? Number(data.settings.maxpendingenrollments)
|
||||
: DEFAULT_PENDING_CAP
|
||||
@@ -771,6 +797,26 @@ function onAutoEnrollToggle(event) {
|
||||
})
|
||||
}
|
||||
|
||||
function onRevokedDeniesToggle(event) {
|
||||
const turningOn = event.target.checked
|
||||
if (turningOn) {
|
||||
// Typed confirm, like the shared-key switch: this one silences PCs that
|
||||
// are reporting right now, and the revoke-plus-rollback recipe in the
|
||||
// runbook stops working the moment it is on.
|
||||
const answer = prompt(
|
||||
'Deny the shared key to revoked PCs?\n\n'
|
||||
+ 'Any PC whose credential was revoked stops reporting and stops '
|
||||
+ 'receiving commands until it enrolls again. The revoke + block '
|
||||
+ 're-enrollment rollback onto an older agent no longer works.\n\n'
|
||||
+ 'Type DENY to confirm.')
|
||||
if (!answer || answer.trim().toUpperCase() !== 'DENY') {
|
||||
event.target.checked = false
|
||||
return
|
||||
}
|
||||
}
|
||||
return saveSettings({ revokeddeniessharedkey: turningOn })
|
||||
}
|
||||
|
||||
function onPendingCapChange() {
|
||||
const wanted = Math.trunc(Number(pendingCapInput.value))
|
||||
if (!Number.isFinite(wanted) || wanted < 0) {
|
||||
|
||||
@@ -4,16 +4,25 @@
|
||||
One row per asset. Holds only the sha256 of the secret the
|
||||
agent was issued, plus a short display prefix. Revoking sets
|
||||
revokedat; re-issuing after a revoke UPDATES the same row, so
|
||||
an asset never carries two credentials. reenrollblocked keeps
|
||||
an asset never carries two credentials. The row therefore also
|
||||
says that this PC has enrolled at least once, which is how
|
||||
agentauth tells a revoked PC from one that never enrolled:
|
||||
plugin.endpoints.revokeddeniessharedkey refuses the shared key
|
||||
to the first and never to the second. reenrollblocked keeps
|
||||
a revoked PC from enrolling again (rolling it back to an agent
|
||||
that only knows the shared key, or a compromised PC) until an
|
||||
admin allows it.
|
||||
|
||||
EndpointEnrollRequest
|
||||
One row per (hostname, serial number) that asked to enroll. The
|
||||
agent's enrollment nonce is recorded on first sight and is what
|
||||
an admin approval is bound to, so a second machine claiming the
|
||||
same hostname and serial cannot collect the approved credential.
|
||||
One row per (hostname, serial number) that asked to enroll -
|
||||
the endpoint's identity, and the reason every contact from one
|
||||
PC lands on one row. The agent's enrollment nonce is recorded
|
||||
on first sight and is what an admin approval is bound to, so a
|
||||
second machine claiming the same hostname and serial cannot
|
||||
collect the approved credential. A REJECTED row binds to the
|
||||
identity instead: any nonce from that hostname and serial stays
|
||||
rejected, so a reinstall cannot argue its way back into the
|
||||
queue. Only an admin approving the request clears it.
|
||||
|
||||
EndpointMigrationWave
|
||||
An admin action that pushed the agent upgrade to a set of PCs.
|
||||
|
||||
@@ -287,6 +287,15 @@ class EndpointsPlugin(BasePlugin):
|
||||
making revocation an instant kill switch. `agentkeyprovisioned` records
|
||||
that a UI-managed key has been minted so the settings page can render
|
||||
the rotate/revoke state instead of the first-run generate state.
|
||||
|
||||
Every key an enforcement path reads belongs here, even when its default
|
||||
is what the code already does with no row at all. Undeclared, the row
|
||||
is only born when somebody writes it and the settings API has to guess
|
||||
its type - which is how bootstrapquerytoken became a string row that
|
||||
its boolean reader could never see turned off. Declared, the row is
|
||||
seeded at install and enable (and by `flask plugin upgrade-all` on a
|
||||
site that installed an earlier version), so the settings pages have
|
||||
something to render and an operator has something to click.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
@@ -318,6 +327,41 @@ class EndpointsPlugin(BasePlugin):
|
||||
'approval when the hostname and serial number '
|
||||
'match exactly one computer.',
|
||||
},
|
||||
{
|
||||
'key': 'plugin.endpoints.revokeddeniessharedkey',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'plugin.endpoints',
|
||||
'description': 'Also refuse the shared fleet key to a PC whose '
|
||||
'per-PC credential was revoked. Off (the '
|
||||
'default) is what the fleet does today: a '
|
||||
'revoked PC falls back to the shared key, so a '
|
||||
'credential rotation and the documented revoke '
|
||||
'plus blockreenroll rollback to an older agent '
|
||||
'both keep reporting. Turning it on makes a '
|
||||
'revoke an eviction - that PC reports nothing '
|
||||
'and receives no commands until it enrolls '
|
||||
'again - and it stops that rollback working. '
|
||||
'Safe only once every PC holds its own '
|
||||
'credential.',
|
||||
},
|
||||
{
|
||||
'key': 'plugin.endpoints.bootstrapquerytoken',
|
||||
'value': 'true',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'plugin.endpoints',
|
||||
'description': 'Accept a one-line install bootstrap token in '
|
||||
'the ?t= query string as well as the '
|
||||
'X-Endpoint-Bootstrap-Token header. On (the '
|
||||
'default) '
|
||||
'keeps the pasted install commands and the '
|
||||
'Download .ps1 link working; the query form is '
|
||||
'deprecated because the token lands in IIS and '
|
||||
'proxy logs. Turn it off only after every '
|
||||
'outstanding install command has been re-issued '
|
||||
'- an in-flight one stops working the moment it '
|
||||
'is off.',
|
||||
},
|
||||
{
|
||||
'key': 'plugin.endpoints.agentkeyprovisioned',
|
||||
'value': 'false',
|
||||
|
||||
@@ -1,10 +1,46 @@
|
||||
"""Notifications plugin models - adapted to existing database schema."""
|
||||
|
||||
import json
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class JSONText(TypeDecorator):
|
||||
"""A dict on the Python side, TEXT in the database.
|
||||
|
||||
MySQL 5.6 has no JSON type and a site still runs it, so every JSON-bearing
|
||||
column in this product is TEXT. Declared here rather than imported from the
|
||||
computers plugin: notifications declares no dependency on computers, and a
|
||||
cross-plugin import would be a hidden one.
|
||||
"""
|
||||
|
||||
impl = Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, separators=(',', ':'))
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None or value == '':
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
# Hand-edited or written before this column held JSON. Return the
|
||||
# raw text rather than raising: a scope that cannot be parsed
|
||||
# should be visible and fixable, and the endpoints matcher already
|
||||
# treats a non-dict scope as matching nothing.
|
||||
return value
|
||||
|
||||
|
||||
_DEFAULT_TZ = 'America/New_York'
|
||||
|
||||
# Keys a fleet alert's alertscope may use. Must stay equal to the set the
|
||||
@@ -155,7 +191,11 @@ class Notification(db.Model):
|
||||
# Matches migration 0006: NOT NULL with server defaults.
|
||||
alertfleet = db.Column(db.Boolean, nullable=False, default=False,
|
||||
server_default=db.false())
|
||||
alertscope = db.Column(db.JSON, nullable=True)
|
||||
# JSONText, not db.JSON: migration 0006 ships this column as TEXT because a
|
||||
# site still runs MySQL 5.6, which has no JSON type. db.JSON here meant
|
||||
# create-all built a JSON column the migration path never produces, so the
|
||||
# model and the shipped schema disagreed about the same column.
|
||||
alertscope = db.Column(JSONText, nullable=True)
|
||||
alerttimeout = db.Column(db.Integer, nullable=False, default=0,
|
||||
server_default='0')
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ import re
|
||||
from flask import current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import Setting
|
||||
from shopdb.api import Setting, client_ip, ip_in_cidrs
|
||||
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
||||
IMAGE_URL_PREFIX = '/api/printedparts/image/'
|
||||
@@ -518,16 +518,39 @@ def adjust_item(item_id: int):
|
||||
|
||||
import hmac
|
||||
|
||||
from shopdb.api import audit_log
|
||||
|
||||
# Largest single take the kiosk records. A shop-floor grab is single digits; 25
|
||||
# leaves real use untouched and denies draining a bin in one request.
|
||||
KIOSK_MAX_TAKE_QUANTITY = 25
|
||||
|
||||
# Addresses that belong to the PATH, not to a caller. Subtracted from the
|
||||
# allowlist before it is consulted.
|
||||
#
|
||||
# WHY, and it is the half that was missing here: a reverse proxy normally sits
|
||||
# ON the network a site enters in printedparts_kiosk_allowed_cidrs. A request
|
||||
# that arrives wearing the proxy's own address - because the proxy forwarded
|
||||
# nothing, or because nothing forwarded at all - therefore MATCHED the allowlist
|
||||
# and was admitted while carrying no evidence about who actually sent it. Being
|
||||
# inside the allowlist is not enough; the address also has to be one a client
|
||||
# could plausibly have had.
|
||||
#
|
||||
# Loopback is never a client at any site, so it needs no configuration. The USB
|
||||
# kiosk closed this on 2026-09-18 and this one did not, which is the only reason
|
||||
# the two read differently.
|
||||
PARTS_KIOSK_TRUSTED_PROXIES = 'printedparts_kiosk_trusted_proxies'
|
||||
PARTS_KIOSK_NEVER_A_CLIENT = '127.0.0.0/8, ::1/128'
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when this site has the kiosk on AND the caller carries its token."""
|
||||
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
|
||||
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
|
||||
return False
|
||||
|
||||
def _kiosk_address_is_infrastructure(address):
|
||||
"""True when this address belongs to the path rather than to a caller."""
|
||||
if ip_in_cidrs(address, PARTS_KIOSK_NEVER_A_CLIENT):
|
||||
return True
|
||||
return ip_in_cidrs(address, Setting.get(PARTS_KIOSK_TRUSTED_PROXIES))
|
||||
|
||||
|
||||
def _kiosk_token_presented():
|
||||
"""True when the caller carries this kiosk's device token."""
|
||||
expected = (Setting.get('printedparts_kiosk_token') or '').strip()
|
||||
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
if not expected or not presented:
|
||||
@@ -537,6 +560,50 @@ def _kiosk_open():
|
||||
return hmac.compare_digest(presented, expected)
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when this site has the kiosk on AND the caller is admitted.
|
||||
|
||||
Two ways in, either sufficient:
|
||||
1. the per-device token, for the unattended screen by the printers, and
|
||||
2. a source address inside printedparts_kiosk_allowed_cidrs, for people
|
||||
who open the page on their own PC and never log in.
|
||||
|
||||
Its own setting, not the USB kiosk's: the two are separate screens in
|
||||
separate rooms, and one allowlist granting the other is exactly the sharing
|
||||
the separate tokens exist to prevent.
|
||||
"""
|
||||
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
|
||||
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
|
||||
return False
|
||||
if _kiosk_token_presented():
|
||||
return True
|
||||
address = client_ip(request)
|
||||
if _kiosk_address_is_infrastructure(address):
|
||||
return False
|
||||
return ip_in_cidrs(address, Setting.get('printedparts_kiosk_allowed_cidrs'))
|
||||
|
||||
|
||||
@printedparts_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
||||
@require_permission('settings.edit')
|
||||
def kiosk_provisioning_link():
|
||||
"""The URL that provisions a screen, token included, for an administrator.
|
||||
|
||||
Same reason as the USB kiosk: the settings API masks any key named
|
||||
'*token*', so the value is visible for one moment after Generate and an
|
||||
administrator who copies the field later provisions a screen with
|
||||
'********' and gets a silent 404. This hands the real value to somebody
|
||||
already allowed to set it, so the mask can stay.
|
||||
"""
|
||||
token = (Setting.get('printedparts_kiosk_token') or '').strip()
|
||||
if not token:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'No kiosk token set. Generate one first.')
|
||||
audit_log('viewed', 'Setting', entityname='printedparts_kiosk_token',
|
||||
details='kiosk provisioning link')
|
||||
db.session.commit()
|
||||
return success_response({'url': '/parts-kiosk?token=' + token})
|
||||
|
||||
|
||||
def _kiosk_shut():
|
||||
"""404, not 403 - a caller without the token does not learn the door exists."""
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
||||
|
||||
@@ -55,7 +55,13 @@
|
||||
<button type="button" class="btn btn-secondary" @click="generateToken">
|
||||
Generate
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="copyLink">
|
||||
Copy setup link
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="linkMessage" class="field-hint">{{ linkMessage }}</p>
|
||||
<input v-if="link" :value="link" type="text" class="form-control"
|
||||
readonly @focus="$event.target.select()" />
|
||||
<p class="field-hint">
|
||||
Each kiosk screen is provisioned once by opening
|
||||
<code>/parts-kiosk?token=<this value></code> on it. The browser
|
||||
@@ -68,6 +74,30 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Networks allowed without the token</label>
|
||||
<NetworkAllowlistPicker v-model="values.printedparts_kiosk_allowed_cidrs" />
|
||||
<p class="field-hint">
|
||||
For people who open the kiosk on their own PC rather than at the
|
||||
screen. A browser on one of these networks is admitted with no token
|
||||
and no login, so keep this to the shop-floor ranges. Leave empty to
|
||||
require the token from every caller. Separate from the USB kiosk
|
||||
allowlist: one does not grant the other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Reverse proxies in front of this server</label>
|
||||
<NetworkAllowlistPicker v-model="values.printedparts_kiosk_trusted_proxies" />
|
||||
<p class="field-hint">
|
||||
Only needed when a proxy sits between the kiosk and this server. A
|
||||
request arriving FROM one of these addresses is never treated as a
|
||||
kiosk, so a proxy inside an allowed network above cannot admit itself.
|
||||
Loopback is always excluded and needs no entry. Leave empty when
|
||||
browsers reach this server directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
@@ -143,6 +173,34 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
import api from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
// The settings API masks the token, so the field shows '********'. Copying that
|
||||
// provisions a screen with the mask and every kiosk call answers 404 with
|
||||
// nothing to say why. Ask the server for the real link instead.
|
||||
const link = ref('')
|
||||
const linkMessage = ref('')
|
||||
|
||||
async function copyLink () {
|
||||
linkMessage.value = ''
|
||||
link.value = ''
|
||||
try {
|
||||
const response = await api.get('/printedparts/kiosk/provisioning-link')
|
||||
const url = window.location.origin + withBase(response.data.data.url)
|
||||
link.value = url
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
linkMessage.value = 'Copied. Open this once on the kiosk screen.'
|
||||
} catch (clipboardError) {
|
||||
linkMessage.value = 'Copy this and open it once on the kiosk screen.'
|
||||
}
|
||||
} catch (linkError) {
|
||||
linkMessage.value = linkError.response?.data?.data?.error?.message
|
||||
|| 'Could not build the setup link.'
|
||||
}
|
||||
}
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
@@ -150,6 +208,8 @@ const KEYS = [
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_kiosk_enabled',
|
||||
'printedparts_kiosk_token',
|
||||
'printedparts_kiosk_allowed_cidrs',
|
||||
'printedparts_kiosk_trusted_proxies',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
@@ -163,6 +223,8 @@ const values = ref({
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_kiosk_enabled: 'false',
|
||||
printedparts_kiosk_token: '',
|
||||
printedparts_kiosk_allowed_cidrs: '',
|
||||
printedparts_kiosk_trusted_proxies: '',
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"display_name": "3D Printed Parts",
|
||||
"author": "",
|
||||
"dependencies": ["employees"],
|
||||
"core_version": ">=0.16.0,<1.0.0",
|
||||
"core_version": ">=0.35.0,<1.0.0",
|
||||
"api_prefix": "/api/printedparts",
|
||||
"default_enabled": false
|
||||
}
|
||||
|
||||
@@ -172,6 +172,36 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'Generate one, then open the kiosk once as '
|
||||
'/parts-kiosk?token=<value> on each screen',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_kiosk_trusted_proxies',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printedparts',
|
||||
'description': 'Addresses or CIDRs of the reverse proxies and '
|
||||
'load balancers in front of this server, comma '
|
||||
'separated. SUBTRACTED from the kiosk allowlist '
|
||||
'below: a request wearing one of these addresses '
|
||||
'is the proxy talking about itself, so it is '
|
||||
'refused rather than admitted by a range that '
|
||||
'happens to contain the proxy. Loopback is '
|
||||
'always subtracted and needs no entry. Leave '
|
||||
'empty when browsers reach this server directly.',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_kiosk_allowed_cidrs',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printedparts',
|
||||
'description': 'Networks allowed to use the kiosk WITHOUT the '
|
||||
'device token, as comma-separated CIDRs (for '
|
||||
'example 192.0.2.0/24). For people who open '
|
||||
'the kiosk on their own PC and never log in. '
|
||||
'Empty = token only. Separate from the USB '
|
||||
'kiosk allowlist on purpose. Requires the IIS '
|
||||
'X-Forwarded-For rewrite rule to be live, or the '
|
||||
'app sees the proxy address and this matches '
|
||||
'nothing.',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_unknown_badge',
|
||||
'value': 'deny',
|
||||
|
||||
@@ -20,13 +20,17 @@ cmmc_usb schema used here:
|
||||
'YYYY-MM-DD HH:MM:SS' string; scanned_viruses/sanitized are '0'|'1'|null.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import hmac
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask import Blueprint, current_app, redirect, request, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
@@ -39,7 +43,7 @@ from shopdb.api import (
|
||||
get_pagination_params,
|
||||
require_permission,
|
||||
)
|
||||
from shopdb.api import Setting
|
||||
from shopdb.api import Setting, audit_log, cache, client_ip, db, ip_in_cidrs
|
||||
|
||||
from . import selfhosted
|
||||
|
||||
@@ -743,8 +747,132 @@ def _kiosk_enabled():
|
||||
return _kiosk_setting('usb_kiosk_enabled', False)
|
||||
|
||||
|
||||
# The device token also travels as a cookie, set by the setup exchange below,
|
||||
# so it never has to sit in a URL to reach a screen.
|
||||
KIOSK_TOKEN_COOKIE = 'usbkiosktoken'
|
||||
|
||||
# Where this site's reverse proxies are, as CIDRs. NOT a source of addresses -
|
||||
# see the block below - but a subtraction from the allowlist: a request wearing
|
||||
# one of these addresses is infrastructure talking, never a caller.
|
||||
KIOSK_TRUSTED_PROXIES = 'usb_kiosk_trusted_proxies'
|
||||
|
||||
# Addresses that are never a kiosk client at any site, whatever was typed into
|
||||
# the allowlist. Loopback is the shipped Windows stack with no forwarding header
|
||||
# at all: HttpPlatformHandler connects from 127.0.0.1, waitress has nothing to
|
||||
# promote, and every caller then arrives wearing the app server's own address.
|
||||
KIOSK_NEVER_A_CLIENT = '127.0.0.0/8, ::1/128'
|
||||
|
||||
|
||||
def _kiosk_token_presented():
|
||||
"""True when the caller presents the configured device token.
|
||||
|
||||
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
|
||||
a token served inside the page would be readable by anyone who could load
|
||||
it.
|
||||
|
||||
Two carriers, one value. A screen provisioned the old way keeps it in
|
||||
localStorage and sends X-Kiosk-Token; a screen provisioned through
|
||||
kiosk_setup holds it in an HttpOnly cookie that the browser sends on its
|
||||
own. Accepting both is what lets the URL-borne token go away without
|
||||
reprovisioning every screen already on the floor.
|
||||
"""
|
||||
expected = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
if not expected:
|
||||
return False
|
||||
presented = ((request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
or (request.cookies.get(KIOSK_TOKEN_COOKIE) or '').strip())
|
||||
if not presented:
|
||||
return False
|
||||
# compare_digest, not ==: == leaks the token's length and matching prefix
|
||||
# through timing.
|
||||
return hmac.compare_digest(presented, expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The address a caller is admitted on, traced through the stack that ships
|
||||
# ---------------------------------------------------------------------------
|
||||
# IIS -> HttpPlatformHandler -> waitress on 127.0.0.1 -> this app, and waitress
|
||||
# is launched with --trusted-proxy=127.0.0.1
|
||||
# --trusted-proxy-headers=x-forwarded-for --trusted-proxy-count=1
|
||||
# (deploy/windows/web.config). HttpPlatformHandler ALWAYS connects over
|
||||
# loopback, so waitress always sees its configured trusted proxy as the peer: it
|
||||
# takes the LAST X-Forwarded-For hop, writes it into REMOTE_ADDR, and truncates
|
||||
# the environ header to that one hop, all before Flask is entered.
|
||||
#
|
||||
# So request.remote_addr IS the forwarded address by the time anything here
|
||||
# runs, and X-Forwarded-For carries a single entry equal to it. That is why the
|
||||
# version of this code that walked the header could not work on the Windows
|
||||
# deployment: it looked for the peer inside usb_kiosk_trusted_proxies before it
|
||||
# would read the header, and the peer is never the proxy any more - waitress
|
||||
# already replaced it with whatever the proxy forwarded. The branch could not
|
||||
# fire, and the parsing under it was inert.
|
||||
#
|
||||
# THIS APP TAKES THE WSGI LAYER'S WORD, and the app-level parsing is gone. Not
|
||||
# because that word is trustworthy in itself, but because Python is the wrong
|
||||
# place to adjudicate it: the proxy's own address is destroyed before Flask is
|
||||
# reached, so nothing here can tell a hop IIS wrote from one a caller sent.
|
||||
# Whether the address is real is settled in front of waitress, and only there:
|
||||
# -ClientIpSource direct: the IIS rewrite rule OVERWRITES X-Forwarded-For with
|
||||
# IIS's own REMOTE_ADDR, so a caller-sent header is destroyed on arrival.
|
||||
# -ClientIpSource proxy: the reverse proxy APPENDS the caller, so the last hop
|
||||
# is the address that proxy itself observed.
|
||||
# Under either, a client-sent header cannot choose the value waitress promotes.
|
||||
# The installer checks this in both modes and, when it does not hold, says to
|
||||
# leave the kiosk allowlist empty (deploy/windows/installer/shopdb-install.ps1).
|
||||
#
|
||||
# Reading request.headers['X-Forwarded-For'] here would undo all of it: on a box
|
||||
# whose front end does not overwrite, that header is the caller's to invent, and
|
||||
# the kiosk would admit on a value the caller's network position does not
|
||||
# support. So it is not read. Anywhere in this file.
|
||||
|
||||
|
||||
def _kiosk_client_ip():
|
||||
"""The caller address, as the layer in front of Flask resolved it."""
|
||||
return client_ip(request)
|
||||
|
||||
|
||||
def _kiosk_address_is_infrastructure(address):
|
||||
"""True when this address belongs to the path, not to a caller.
|
||||
|
||||
The fail-closed half of network admission, and the half that was missing. A
|
||||
reverse proxy normally sits ON the plant network a site enters in
|
||||
usb_kiosk_allowed_cidrs. A request that arrives wearing the proxy's own
|
||||
address - because the proxy forwarded nothing, or because nothing forwarded
|
||||
at all - therefore MATCHED the allowlist and was admitted, while carrying no
|
||||
evidence whatever about who sent it. That is the one case that has to
|
||||
refuse.
|
||||
|
||||
Being inside the allowlist is not enough on its own: the address also has to
|
||||
be one a client could plausibly have had. The previous version only looked
|
||||
closed because the proxy in its test happened to sit outside the allowlist.
|
||||
"""
|
||||
if ip_in_cidrs(address, KIOSK_NEVER_A_CLIENT):
|
||||
return True
|
||||
return ip_in_cidrs(address, Setting.get(KIOSK_TRUSTED_PROXIES))
|
||||
|
||||
|
||||
def _kiosk_ip_allowlisted():
|
||||
"""True when the caller sits in the configured kiosk network allowlist.
|
||||
|
||||
Why this exists alongside the token: the token model assumes a handful of
|
||||
fixed screens provisioned once. Most people reach this kiosk from their own
|
||||
PC instead, and there is no login - so per-browser provisioning would mean
|
||||
provisioning every PC on the floor, and losing it on every reimage or
|
||||
cleared profile. Network position is what those callers actually have in
|
||||
common.
|
||||
|
||||
Fail-closed behaviour is documented on ip_in_cidrs; where the caller address
|
||||
comes from is on _kiosk_client_ip, and which addresses can never be a caller
|
||||
at all is on _kiosk_address_is_infrastructure.
|
||||
"""
|
||||
address = _kiosk_client_ip()
|
||||
if _kiosk_address_is_infrastructure(address):
|
||||
return False
|
||||
return ip_in_cidrs(address, Setting.get('usb_kiosk_allowed_cidrs'))
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when the kiosk is on for this site AND the caller has its token.
|
||||
"""True when the kiosk is on for this site AND the caller is admitted.
|
||||
|
||||
The flag alone was the whole gate, and a flag is a decision, not a
|
||||
credential: with the kiosk on, anyone who could reach the app could move a
|
||||
@@ -752,20 +880,18 @@ def _kiosk_open():
|
||||
badge they guessed. A badge is a short numeric SSO, so it is attribution,
|
||||
never authorization - the same reasoning that closed the parts kiosk.
|
||||
|
||||
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
|
||||
a token served inside the page would be readable by anyone who could load
|
||||
it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
|
||||
it in that browser and sends it as X-Kiosk-Token.
|
||||
Two ways in, either sufficient, mirroring what the GE-Enforce client
|
||||
endpoints already do:
|
||||
1. the per-device token, for unattended screens by the cabinet, and
|
||||
2. a source address inside usb_kiosk_allowed_cidrs, for the people who
|
||||
open this page on their own PC and never log in.
|
||||
|
||||
Both are still gated on usb_kiosk_enabled, so the door stays shut until a
|
||||
site opens it deliberately.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return False
|
||||
expected = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
if not expected or not presented:
|
||||
return False
|
||||
# compare_digest, not ==: == leaks the token's length and matching prefix
|
||||
# through timing.
|
||||
return hmac.compare_digest(presented, expected)
|
||||
return _kiosk_token_presented() or _kiosk_ip_allowlisted()
|
||||
|
||||
|
||||
def _kiosk_disabled_response():
|
||||
@@ -783,6 +909,387 @@ def _kiosk_badge(data):
|
||||
return badge, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What a site may store in a kiosk CIDR list
|
||||
# ---------------------------------------------------------------------------
|
||||
# These two settings decide who may write without logging in, and the settings
|
||||
# API stored whatever was typed: it checks valuetype and nothing else, and has
|
||||
# no per-key hook a plugin can declare. So '0.0.0.0/0' saved cleanly and turned
|
||||
# the kiosk into an open write for anything that could reach the server.
|
||||
#
|
||||
# Checked at the SAVE (see validate_kiosk_settings_write, registered by the
|
||||
# plugin), because that is the only moment an administrator finds out. Refusing
|
||||
# a range at admission time instead would look exactly like the setting quietly
|
||||
# not working.
|
||||
|
||||
KIOSK_CIDR_KEYS = ('usb_kiosk_allowed_cidrs', 'usb_kiosk_trusted_proxies')
|
||||
KIOSK_WIDE_OVERRIDE = 'usb_kiosk_allow_wide_cidrs'
|
||||
# A /16 is already 65,536 addresses. Anything wider is a claim about the site
|
||||
# that a site should have to make on purpose, not a typo that widens a door.
|
||||
KIOSK_MIN_PREFIX = {4: 16, 6: 48}
|
||||
|
||||
|
||||
def kiosk_cidr_problem(raw, allowwide=False):
|
||||
"""Why this CIDR list may not be stored, or None when it may.
|
||||
|
||||
The message names the entry and says what it would mean, because the person
|
||||
reading it is fixing a range on a settings page, not debugging a validator.
|
||||
"""
|
||||
for part in (raw or '').split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.ip_network(part, strict=False)
|
||||
except ValueError:
|
||||
return ('"{0}" is not a network. Use address/prefix, for example '
|
||||
'192.0.2.0/24.'.format(part))
|
||||
if network.prefixlen == 0:
|
||||
return ('"{0}" is every address there is. The kiosk writes without '
|
||||
'a login, so this would let anything that can reach the '
|
||||
'server move a drive against a badge it guessed. Name the '
|
||||
'shop-floor ranges instead.'.format(part))
|
||||
floor = KIOSK_MIN_PREFIX[network.version]
|
||||
if network.prefixlen < floor and not allowwide:
|
||||
return ('"{0}" covers {1} addresses, which is wider than a site '
|
||||
'range (/{2} or narrower). Everyone inside it is admitted '
|
||||
'with no token and no login. Narrow it, or turn on "{3}" '
|
||||
'if this site really is one flat network that '
|
||||
'wide.'.format(part, network.num_addresses, floor,
|
||||
KIOSK_WIDE_OVERRIDE))
|
||||
return None
|
||||
|
||||
|
||||
# Both settings routes are gated on this one permission. Named here because the
|
||||
# guard below has to apply the SAME gate the view would, and a guard that
|
||||
# guessed a weaker one would be the hole it is meant to close.
|
||||
SETTINGS_WRITE_PERMISSION = 'settings.edit'
|
||||
|
||||
|
||||
def _settings_write_refusal():
|
||||
"""The view's own 401/403 for this caller, or None when it may write.
|
||||
|
||||
A before_request runs ahead of the view, so it runs ahead of
|
||||
@require_permission too. That ordering leaked the validation rules: an
|
||||
anonymous PUT of '0.0.0.0/0' was answered 400 with the reason, while an
|
||||
anonymous PUT of a sane range fell through to the view and was answered 401.
|
||||
The difference is an oracle - anyone who could reach the app could walk the
|
||||
allowlist rules without ever holding an account.
|
||||
|
||||
So authentication decides first, by running the same decorator the view
|
||||
carries rather than a local imitation of it. Guest grants, scoped personal
|
||||
tokens and the admin bypass therefore behave identically here and there, and
|
||||
the response an unauthorized caller gets is the one it would have got if
|
||||
this guard did not exist.
|
||||
"""
|
||||
@require_permission(SETTINGS_WRITE_PERMISSION)
|
||||
def permitted():
|
||||
return None
|
||||
|
||||
return permitted()
|
||||
|
||||
|
||||
def validate_kiosk_settings_write():
|
||||
"""before_request guard on the core settings save, for the kiosk CIDR keys.
|
||||
|
||||
Registered app-wide by the plugin (init_app): the keys belong to this
|
||||
plugin, so this plugin is what knows a bad value, and returning a response
|
||||
from before_request answers the save without performing it.
|
||||
"""
|
||||
if request.endpoint not in ('settings.update_setting',
|
||||
'settings.create_setting'):
|
||||
return None
|
||||
data = request.get_json(silent=True) or {}
|
||||
if request.endpoint == 'settings.update_setting':
|
||||
key = (request.view_args or {}).get('key')
|
||||
else:
|
||||
key = data.get('key')
|
||||
if key not in KIOSK_CIDR_KEYS or 'value' not in data:
|
||||
return None
|
||||
refusal = _settings_write_refusal()
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
# _kiosk_setting, not bool(Setting.get(...)): a row stored as a string holds
|
||||
# the word 'false', and bool('false') is True - which would turn the
|
||||
# override on for a site that had just turned it off.
|
||||
problem = kiosk_cidr_problem(str(data.get('value') or ''),
|
||||
_kiosk_setting(KIOSK_WIDE_OVERRIDE, False))
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Badge lookups: a bounded number of misses per caller
|
||||
# ---------------------------------------------------------------------------
|
||||
# Every kiosk call that takes a badge answers with the employee's NAME, which is
|
||||
# what makes the kiosk usable and also makes it a lookup table: a badge is a
|
||||
# short number, so a caller who is admitted can walk it. Admission used to mean
|
||||
# one screen by the cabinet; with the network allowlist it can mean a whole
|
||||
# VLAN, and that changes what the lookup is worth to somebody with a loop.
|
||||
#
|
||||
# Only MISSES are counted. An ordinary day at the cabinet produces none - a miss
|
||||
# is a scan of something this site has never heard of - so the budget can sit
|
||||
# well above a bad scanning day and still stop enumeration inside a minute.
|
||||
# Same fixed-window cache shape as the login limiter in shopdb.core.api.auth:
|
||||
# under SimpleCache the counter is per-process, which is defence in depth rather
|
||||
# than a hard quota.
|
||||
|
||||
BADGE_MISS_MAX = 15
|
||||
BADGE_MISS_WINDOW_SECONDS = 600
|
||||
|
||||
# The two of those an operator would ever reach for, as settings. They were
|
||||
# app-config only, which on an air-gapped server means hand-editing
|
||||
# instance/config.py and restarting IIS - so in practice the limiter had no off
|
||||
# switch and no dial at all, and the one lever anybody had was to turn the whole
|
||||
# kiosk off. Both default to exactly what the constants above did.
|
||||
#
|
||||
# The WINDOW deliberately stays app-config. It is the shape of the limiter, not
|
||||
# a number a site tunes; a budget expressed per ten minutes is the thing being
|
||||
# decided, and offering two interacting dials invites a combination nobody
|
||||
# meant.
|
||||
KIOSK_BADGE_LIMIT_ENABLED = 'usb_kiosk_badge_limit_enabled'
|
||||
KIOSK_BADGE_MISS_MAX = 'usb_kiosk_badge_miss_max'
|
||||
|
||||
|
||||
def _kiosk_int_setting(key, default):
|
||||
"""A positive whole number from a setting, else default.
|
||||
|
||||
A settings page stores text, so a blank, a word or a pasted range can all
|
||||
arrive here. Any of those has to land on the shipped budget rather than on
|
||||
zero, because zero is not "no limit" in this counter - it refuses every
|
||||
badge lookup the site has from the first scan of the window.
|
||||
"""
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
raw = row.value if row is not None and row.value is not None else ''
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def _badge_miss_budget():
|
||||
# Config first so instance/config.py and the test fixtures keep overriding,
|
||||
# then the setting, then the shipped number.
|
||||
maxmisses = current_app.config.get('USB_KIOSK_BADGE_MISS_MAX')
|
||||
if maxmisses is None:
|
||||
maxmisses = _kiosk_int_setting(KIOSK_BADGE_MISS_MAX, BADGE_MISS_MAX)
|
||||
return (current_app.config.get('USB_KIOSK_BADGE_WINDOW_SECONDS',
|
||||
BADGE_MISS_WINDOW_SECONDS),
|
||||
maxmisses)
|
||||
|
||||
|
||||
def _badge_miss_key(window):
|
||||
bucket = int(time.time() // window) if window > 0 else 0
|
||||
return 'usbkioskbadgemiss:{0}:{1}'.format(
|
||||
_kiosk_client_ip() or 'unknown', bucket)
|
||||
|
||||
|
||||
def _badge_limit_enabled():
|
||||
"""On everywhere but a test run, where the shared cache would bleed.
|
||||
|
||||
The counter lives in the process-wide cache, which outlives a test and is
|
||||
shared by whatever else lands on the same xdist worker - so misses spent in
|
||||
one module surface as an unrelated 429 in another, and which modules share a
|
||||
worker moves around. AUTH_RATELIMIT_ENABLED and the endpoints enrollment
|
||||
limiter are switched off in TestingConfig for exactly that reason; this one
|
||||
is younger than that file and defaults itself the same way.
|
||||
|
||||
Order: an explicit app config value (instance/config.py, and the fixtures in
|
||||
the test suite) wins, then TESTING, then the site setting, which ships on.
|
||||
A site turning this off is a real decision - the kiosk answers with an
|
||||
employee NAME, so an unbounded badge lookup is a directory anyone admitted
|
||||
can walk - which is why it is reachable rather than buried in a config file
|
||||
nobody on an air-gapped box can edit without a maintenance window.
|
||||
"""
|
||||
override = current_app.config.get('USB_KIOSK_BADGE_LIMIT_ENABLED')
|
||||
if override is not None:
|
||||
return bool(override)
|
||||
if current_app.config.get('TESTING', False):
|
||||
return False
|
||||
return _kiosk_setting(KIOSK_BADGE_LIMIT_ENABLED, True)
|
||||
|
||||
|
||||
def badge_lookups_exhausted():
|
||||
"""True when this caller has spent its budget of unrecognised badges."""
|
||||
if not _badge_limit_enabled():
|
||||
return False
|
||||
window, maxmisses = _badge_miss_budget()
|
||||
return (cache.get(_badge_miss_key(window)) or 0) >= maxmisses
|
||||
|
||||
|
||||
def record_badge_miss():
|
||||
"""Count one unrecognised scan against this caller.
|
||||
|
||||
Called from both directory modes - the raw-SQL path below and the
|
||||
self-hosted one in selfhosted.py - at the point where a scanned badge
|
||||
resolves to nobody. Holder-name lookups do not come through here: those are
|
||||
the kiosk naming a drive's holder, not a caller guessing.
|
||||
"""
|
||||
if not _badge_limit_enabled():
|
||||
return
|
||||
window, maxmisses = _badge_miss_budget()
|
||||
key = _badge_miss_key(window)
|
||||
count = (cache.get(key) or 0) + 1
|
||||
cache.set(key, count, timeout=window)
|
||||
if count == maxmisses:
|
||||
logger.warning('usb kiosk: %s unrecognised badges from %s within %ss; '
|
||||
'refusing badge lookups for the rest of the window',
|
||||
count, _kiosk_client_ip(), window)
|
||||
|
||||
|
||||
def _badge_lookups_refused():
|
||||
return error_response(
|
||||
'RATE_LIMITED',
|
||||
'Too many badges this screen does not recognise. Wait a few minutes '
|
||||
'and try again, or see IT.', http_code=429)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provisioning a screen without putting the token in a URL
|
||||
# ---------------------------------------------------------------------------
|
||||
# A query string is the one place a credential is guaranteed to be copied:
|
||||
# browser history, the Referer on the next request, the IIS access log, whatever
|
||||
# the proxy keeps. So the link an administrator carries to a screen holds a code
|
||||
# that is good once and briefly, and the token itself comes back in a Set-Cookie
|
||||
# that no log line carries.
|
||||
#
|
||||
# Only the code's DIGEST is stored, so the pending row is not itself a
|
||||
# credential; the expiry rides with it.
|
||||
|
||||
KIOSK_SETUP_CODE = 'usb_kiosk_setup_code'
|
||||
# Long enough to walk from a desk to the cabinet, short enough that a link left
|
||||
# in a history or a log is already dead when somebody reads it back.
|
||||
KIOSK_SETUP_TTL_SECONDS = 30 * 60
|
||||
# The screen is provisioned once and must not quietly fall out of the kiosk
|
||||
# months later, which is the failure this cookie has to avoid above all.
|
||||
KIOSK_COOKIE_MAX_AGE = 5 * 365 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _issue_kiosk_setup_code():
|
||||
"""Mint a single-use code, store its digest and expiry, return the code."""
|
||||
code = secrets.token_urlsafe(24)
|
||||
digest = hashlib.sha256(code.encode()).hexdigest()
|
||||
expiry = int(time.time()) + KIOSK_SETUP_TTL_SECONDS
|
||||
Setting.set(KIOSK_SETUP_CODE, '{0}|{1}'.format(digest, expiry),
|
||||
valuetype='string', category='usb',
|
||||
description='Digest and expiry of the pending kiosk setup '
|
||||
'code. Written and cleared by the setup link; not '
|
||||
'edited by hand.')
|
||||
return code
|
||||
|
||||
|
||||
def _redeem_kiosk_setup_code(code):
|
||||
"""True exactly once per issued code, and never once it has expired.
|
||||
|
||||
Cleared on ANY attempt. A code is worth one screen; burning it on a wrong
|
||||
guess costs an administrator one click and leaves a prober nothing to
|
||||
retry against.
|
||||
"""
|
||||
stored = (Setting.get(KIOSK_SETUP_CODE) or '').strip()
|
||||
if not stored or '|' not in stored:
|
||||
return False
|
||||
Setting.set(KIOSK_SETUP_CODE, '', valuetype='string', category='usb')
|
||||
digest, _, expiry = stored.partition('|')
|
||||
try:
|
||||
if int(expiry) < int(time.time()):
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
return hmac.compare_digest(
|
||||
digest, hashlib.sha256((code or '').encode()).hexdigest())
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
||||
@require_permission('settings.edit')
|
||||
def kiosk_provisioning_link():
|
||||
"""The URL that provisions a screen, token included, for an administrator.
|
||||
|
||||
WHY THIS EXISTS. The settings API masks every key whose name contains
|
||||
'token', so the settings page shows the kiosk token as '********'. The real
|
||||
value is visible for exactly one moment - after Generate, before Save - and
|
||||
an administrator who copies the field at any other time provisions a screen
|
||||
with the literal string '********'. Nothing says so: the kiosk answers 404
|
||||
the same way it answers a caller it has never heard of, because the route
|
||||
deliberately does not advertise itself. That has cost real time.
|
||||
|
||||
So the copy step stops depending on catching that moment. The mask stays -
|
||||
it is right that a token does not travel in an ordinary settings list - and
|
||||
this named, permission-gated route hands over a way in to somebody who is
|
||||
already allowed to set it.
|
||||
|
||||
WHAT IT HANDS OVER IS NOT THE TOKEN. The link used to be
|
||||
'/usb-kiosk?token=<value>', which put the credential in a query string: the
|
||||
address bar, the browser history, a Referer, the IIS log and anything in
|
||||
between. It is now a single-use code that kiosk_setup trades for the token
|
||||
over a Set-Cookie. One click here, one open on the screen - the operator
|
||||
flow is the same.
|
||||
|
||||
Returns a relative URL. The caller's own origin is the right base for a
|
||||
screen being provisioned from that same browser, and the server has no
|
||||
reliable idea what hostname the operator reached it by.
|
||||
"""
|
||||
token = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
if not token:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'No kiosk token set. Generate one first.')
|
||||
code = _issue_kiosk_setup_code()
|
||||
# Issuing a way to claim the token is worth a line in the audit log: the
|
||||
# trail should say who asked, and when the clock on it started.
|
||||
audit_log('viewed', 'Setting', entityname='usb_kiosk_token',
|
||||
details='kiosk setup link issued')
|
||||
db.session.commit()
|
||||
return success_response({
|
||||
'url': '/api/usb/kiosk/setup?code=' + code,
|
||||
'expiresinminutes': KIOSK_SETUP_TTL_SECONDS // 60,
|
||||
})
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/setup', methods=['GET'])
|
||||
def kiosk_setup():
|
||||
"""Trade a setup code for this browser's kiosk cookie, then open the kiosk.
|
||||
|
||||
Opened once on the screen being provisioned. The code is spent here, the
|
||||
token travels back in a Set-Cookie rather than a URL, and the browser is
|
||||
sent on to the kiosk page with nothing left in the address bar.
|
||||
|
||||
HttpOnly, so the page's own JavaScript cannot read the token back out and
|
||||
put it somewhere that does get logged. SameSite=Lax, so another site cannot
|
||||
make this browser POST at the kiosk routes with it. Secure only over https:
|
||||
setting it unconditionally would drop the cookie on the plain-http sites
|
||||
this is deployed on and provision nothing.
|
||||
|
||||
Not gated on usb_kiosk_enabled: a site sets its screens up before opening
|
||||
the door, and a kiosk that is off refuses every call anyway.
|
||||
"""
|
||||
token = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
if not token:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'No kiosk token set. Generate one first.')
|
||||
if not _redeem_kiosk_setup_code(request.args.get('code')):
|
||||
# 410, not 404: this one is read by an operator standing at a screen,
|
||||
# and "gone" plus what to do next is the difference between a working
|
||||
# kiosk and a support call.
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'This setup link has already been used or has expired. Generate a '
|
||||
'new one under Settings > USB.', http_code=410)
|
||||
audit_log('viewed', 'Setting', entityname='usb_kiosk_token',
|
||||
details='kiosk setup link redeemed')
|
||||
db.session.commit()
|
||||
|
||||
mount = request.script_root or ''
|
||||
response = redirect(mount + '/usb-kiosk')
|
||||
response.set_cookie(KIOSK_TOKEN_COOKIE, token,
|
||||
max_age=KIOSK_COOKIE_MAX_AGE, httponly=True,
|
||||
samesite='Lax', secure=request.is_secure,
|
||||
path=mount + '/')
|
||||
# This response carries the token. Nothing anywhere may keep a copy.
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
|
||||
def kiosk_device(device_id):
|
||||
"""What the kiosk shows after a device is scanned.
|
||||
@@ -854,6 +1361,8 @@ def kiosk_session():
|
||||
"""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_session(data)
|
||||
@@ -867,6 +1376,7 @@ def kiosk_session():
|
||||
with conn.cursor() as cur:
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
record_badge_miss()
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT',
|
||||
@@ -912,6 +1422,8 @@ def kiosk_others():
|
||||
return _kiosk_disabled_response()
|
||||
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_others(data)
|
||||
@@ -924,6 +1436,7 @@ def kiosk_others():
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if not _resolve_badge_name(cur, badge):
|
||||
record_badge_miss()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT',
|
||||
http_code=422)
|
||||
@@ -954,6 +1467,8 @@ def kiosk_checkout():
|
||||
"""Check a device out at the kiosk. Body: {device_id, badge}."""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_checkout(data)
|
||||
@@ -988,6 +1503,7 @@ def kiosk_checkout():
|
||||
# kiosk there is no signed-in operator to vouch for it.
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
record_badge_miss()
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to check this device out',
|
||||
@@ -1041,6 +1557,8 @@ def kiosk_checkin():
|
||||
"""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_checkin(data)
|
||||
@@ -1065,6 +1583,7 @@ def kiosk_checkin():
|
||||
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
record_badge_miss()
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to return this device',
|
||||
@@ -1143,7 +1662,8 @@ def _auditcsv(rows, columns, filename):
|
||||
|
||||
|
||||
AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge',
|
||||
'name', 'locker_location']
|
||||
'name', 'locker_location', 'sanitized', 'scanned_viruses']
|
||||
|
||||
|
||||
|
||||
@usb_bp.route('/reports/audit', methods=['GET'])
|
||||
@@ -1169,7 +1689,8 @@ def report_audit():
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
sql = ('SELECT l.timestamp, l.action, l.device_id, l.badge_number, '
|
||||
'l.locker_location, d.device_desc, '
|
||||
'l.locker_location, l.sanitized, l.scanned_viruses, '
|
||||
'd.device_desc, '
|
||||
'u.first_name, u.last_name '
|
||||
'FROM checkinoutlog l '
|
||||
'LEFT JOIN devices d ON d.device_id = l.device_id '
|
||||
@@ -1197,6 +1718,13 @@ def report_audit():
|
||||
'badge': row.get('badge_number') or '',
|
||||
'name': name,
|
||||
'locker_location': row.get('locker_location') or '',
|
||||
# Only a return can carry these: the wipe and the scan
|
||||
# happen when the drive comes back, so a check-out row
|
||||
# showing them would invent a fact.
|
||||
'sanitized': (selfhosted.auditflag(row.get('sanitized'))
|
||||
if row.get('action') == 'check-in' else ''),
|
||||
'scanned_viruses': (selfhosted.auditflag(row.get('scanned_viruses'))
|
||||
if row.get('action') == 'check-in' else ''),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -343,6 +343,27 @@ def kiosk_device(device_id):
|
||||
})
|
||||
|
||||
|
||||
def _badge_not_recognised(suffix=''):
|
||||
"""One unrecognised scan, counted against the caller, then the refusal.
|
||||
|
||||
WHY COUNTED. The answer to a recognised badge is an employee's NAME, and a
|
||||
badge is a short number, so this is a lookup somebody admitted to the kiosk
|
||||
can walk. Network-position admission can now mean a whole VLAN rather than
|
||||
one screen by the cabinet, so the number of badges a caller may guess wrong
|
||||
has to be bounded. The budget lives in routes.py alongside the admission
|
||||
checks; the import is deferred because routes imports this module.
|
||||
|
||||
The wording stays per-action ("to check this device out"), because the
|
||||
person at the screen is being told what did not happen.
|
||||
"""
|
||||
from .routes import record_badge_miss
|
||||
|
||||
record_badge_miss()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT' + suffix,
|
||||
http_code=422)
|
||||
|
||||
|
||||
def kiosk_session(data):
|
||||
"""See routes.kiosk_session for why the list costs a valid badge."""
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
@@ -350,8 +371,7 @@ def kiosk_session(data):
|
||||
return badgeerror
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
return _badge_not_recognised()
|
||||
|
||||
yours, available, others = [], [], 0
|
||||
for device in USBDevice.query.filter_by(isactive=True).order_by(
|
||||
@@ -377,8 +397,7 @@ def kiosk_others(data):
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
if not _resolve_name(badge):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
return _badge_not_recognised()
|
||||
|
||||
out = []
|
||||
for device in USBDevice.query.filter_by(isactive=True, ischeckedout=True
|
||||
@@ -419,10 +438,7 @@ def kiosk_checkout(data):
|
||||
# path. A checkout nobody can trace to a person defeats the tracking.
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to check this device out',
|
||||
http_code=422)
|
||||
return _badge_not_recognised(' to check this device out')
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
# The transition IS the guard. The ischeckedout test above reads, decides
|
||||
@@ -477,10 +493,7 @@ def kiosk_checkin(data):
|
||||
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to return this device',
|
||||
http_code=422)
|
||||
return _badge_not_recognised(' to return this device')
|
||||
|
||||
holderbadge = (device.currentuserid or '').strip()
|
||||
if holderbadge and holderbadge != badge:
|
||||
@@ -544,6 +557,22 @@ def _kiosk_flag(key, default):
|
||||
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
|
||||
def auditflag(value):
|
||||
"""Render a stored tri-state as 'yes', 'no', or '' for the report.
|
||||
|
||||
THREE states, not two. Both stores keep these nullable on purpose: a null
|
||||
means nobody recorded it, which is not the same claim as 'no'. Rendering an
|
||||
unrecorded return as 'No' would put a assertion in an audit record that
|
||||
nobody ever made - the opposite of what the record is for. So unknown stays
|
||||
blank and reads as the gap it is.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return ''
|
||||
if isinstance(value, str):
|
||||
return 'yes' if value.strip() in ('1', 'true', 'True') else 'no'
|
||||
return 'yes' if value else 'no'
|
||||
|
||||
|
||||
def audit_rows(days):
|
||||
"""Check-out / check-in events over a window (self-hosted mode).
|
||||
|
||||
@@ -576,11 +605,16 @@ def audit_rows(days):
|
||||
'locker_location': device.storagelocation or '',
|
||||
}
|
||||
if checkout.checkouttime and (cutoff is None or checkout.checkouttime >= cutoff):
|
||||
# No wipe or scan state on a take: both are recorded on the return,
|
||||
# so putting them here would invent a fact.
|
||||
rows.append(dict(base, timestamp=checkout.checkouttime.isoformat(),
|
||||
action='check-out'))
|
||||
action='check-out',
|
||||
sanitized='', scanned_viruses=''))
|
||||
if checkout.checkintime and (cutoff is None or checkout.checkintime >= cutoff):
|
||||
rows.append(dict(base, timestamp=checkout.checkintime.isoformat(),
|
||||
action='check-in'))
|
||||
action='check-in',
|
||||
sanitized=auditflag(checkout.waswiped),
|
||||
scanned_viruses=auditflag(checkout.scannedviruses)))
|
||||
|
||||
# Newest first, and a total order so two events in the same second do not
|
||||
# swap places between identical runs.
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="refused" class="kiosk-step">
|
||||
<p class="kiosk-prompt">This screen is not set up for the kiosk.</p>
|
||||
<p class="kiosk-hint">
|
||||
The kiosk is on, but this browser was not admitted. A wall screen is
|
||||
provisioned once by opening it with its device token; a PC is admitted
|
||||
by being on an allowed network. An administrator sets both in Settings
|
||||
under USB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||
|
||||
@@ -145,6 +155,12 @@ const error = ref('')
|
||||
const doneMessage = ref('')
|
||||
const busy = ref(false)
|
||||
const disabled = ref(false)
|
||||
// The kiosk is on, but the server refused THIS caller: no device token in this
|
||||
// browser and no allowed network. The server answers 404 rather than 403 so the
|
||||
// route does not advertise itself, which means the client cannot know until it
|
||||
// asks. Without this the page rendered as a working kiosk whose every call
|
||||
// failed - the parts kiosk already says so, and this one did not.
|
||||
const refused = ref(false)
|
||||
const manualBadge = ref('')
|
||||
const entryInput = ref(null)
|
||||
let resetTimer = null
|
||||
@@ -161,8 +177,33 @@ onMounted(async () => {
|
||||
// An unreachable setting is not proof it is off; let the API decide.
|
||||
disabled.value = false
|
||||
}
|
||||
if (!disabled.value) await probeAdmission()
|
||||
focusEntry()
|
||||
})
|
||||
|
||||
// ASK, rather than guess, whether this screen may use the kiosk.
|
||||
//
|
||||
// The enabled flag is public and says only that the site HAS a kiosk; whether
|
||||
// THIS caller is admitted is decided per request, and the server answers 404
|
||||
// rather than 403 so the route does not advertise itself. Without asking, the
|
||||
// page rendered a keypad and only failed once somebody had typed their SSO -
|
||||
// which reads as broken rather than as not set up.
|
||||
//
|
||||
// The parts kiosk answers the same question by checking whether its browser
|
||||
// holds a token. That test is wrong here: a PC admitted by network carries no
|
||||
// token and is perfectly entitled, so it would warn exactly the callers the
|
||||
// allowlist exists to serve. Hence a real request.
|
||||
//
|
||||
// An empty badge is deliberate. The gate runs before any badge handling, so a
|
||||
// refused caller gets 404 and an admitted one gets the badge validation error -
|
||||
// no lookup, no write, nothing recorded either way.
|
||||
async function probeAdmission () {
|
||||
try {
|
||||
await api.post('/usb/kiosk/session', { badge: '' })
|
||||
} catch (probeError) {
|
||||
if (probeError.response?.status === 404) refused.value = true
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
|
||||
function focusEntry (event) {
|
||||
@@ -214,7 +255,11 @@ async function startSession (value) {
|
||||
step.value = 'pick'
|
||||
focusFilter()
|
||||
} catch (sessionError) {
|
||||
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||
if (sessionError.response?.status === 404) {
|
||||
refused.value = true
|
||||
} else {
|
||||
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
focusEntry()
|
||||
|
||||
@@ -41,11 +41,19 @@
|
||||
<button type="button" class="btn btn-secondary" @click="generateToken">
|
||||
Generate
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="copyLink">
|
||||
Copy setup link
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="linkMessage" class="field-hint">{{ linkMessage }}</p>
|
||||
<input v-if="link" :value="link" type="text" class="form-control"
|
||||
readonly @focus="$event.target.select()" />
|
||||
<p class="field-hint">
|
||||
Provision each screen once by opening
|
||||
<code>/usb-kiosk?token=<this value></code> on it. That browser
|
||||
keeps it; the address bar does not. Turning the kiosk on is a
|
||||
Provision each screen once with <strong>Copy setup link</strong>
|
||||
above and open that link on the screen. The link carries a single-use
|
||||
setup code, not the token, so the token never reaches an address bar,
|
||||
a browser history or a proxy log. That browser keeps the token
|
||||
afterwards. Turning the kiosk on is a
|
||||
decision, not a credential - without this token the routes answer 404,
|
||||
so nobody who merely finds the URL can move a drive and sign it with a
|
||||
badge they guessed.
|
||||
@@ -55,6 +63,63 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Reverse proxies in front of this server</label>
|
||||
<NetworkAllowlistPicker v-model="values.usb_kiosk_trusted_proxies" />
|
||||
<p class="field-hint">
|
||||
Only fill this in when a proxy sits between the kiosk screens and this
|
||||
server. A request arriving FROM one of these addresses is never
|
||||
treated as a kiosk itself, so a proxy that sits inside an allowed
|
||||
network below cannot admit itself. Leave empty when browsers reach
|
||||
the server directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Networks allowed without the token</label>
|
||||
<NetworkAllowlistPicker v-model="values.usb_kiosk_allowed_cidrs" />
|
||||
<p class="field-hint">
|
||||
For people who open the kiosk on their own PC. A browser on one of
|
||||
these networks is admitted with no token and no login, so keep this to
|
||||
the shop-floor ranges rather than everything. Leave empty to require
|
||||
the token from every caller. Wall screens keep using the token either
|
||||
way.
|
||||
</p>
|
||||
<p class="field-hint token-warn">
|
||||
Anyone on an allowed network can check a drive in or out against a
|
||||
badge number they guess, and the record carries that person's name.
|
||||
Badges are attribution, not authorization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="switch-label">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_wide_cidrs" />
|
||||
Allow an unusually wide allowed network
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Off, a range wider than a site normally uses is refused when you save,
|
||||
and 0.0.0.0/0 is refused outright. Turn this on only to enter a wide
|
||||
range deliberately: every address in it can move drives with no token
|
||||
and no login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="switch-label">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_badge_limit_enabled" />
|
||||
Limit repeated unknown badge lookups
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
The kiosk turns a badge number into an employee name without a login,
|
||||
so an allowed network can otherwise be walked to enumerate staff. On,
|
||||
a source that misses too many times in a row is refused for a while.
|
||||
</p>
|
||||
<label>Unknown badges allowed before refusing</label>
|
||||
<input v-model="values.usb_kiosk_badge_miss_max" type="text"
|
||||
class="form-control" placeholder="15" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
||||
@@ -80,16 +145,30 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import api, { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
|
||||
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
|
||||
'usb_kiosk_token']
|
||||
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs',
|
||||
'usb_kiosk_trusted_proxies', 'usb_kiosk_allow_wide_cidrs',
|
||||
'usb_kiosk_badge_limit_enabled', 'usb_kiosk_badge_miss_max']
|
||||
|
||||
// Keys whose value is text. Everything else is a switch stored as true/false.
|
||||
// Saving a text setting through the boolean path is what replaced the kiosk
|
||||
// token with the word 'true' and 404'd every provisioned screen.
|
||||
const TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs',
|
||||
'usb_kiosk_trusted_proxies', 'usb_kiosk_badge_miss_max']
|
||||
|
||||
const values = ref({
|
||||
usb_kiosk_enabled: false,
|
||||
usb_kiosk_allowed_cidrs: '',
|
||||
usb_kiosk_allow_return_for_others: true,
|
||||
usb_kiosk_token: '',
|
||||
usb_kiosk_trusted_proxies: '',
|
||||
usb_kiosk_allow_wide_cidrs: false,
|
||||
usb_kiosk_badge_limit_enabled: true,
|
||||
usb_kiosk_badge_miss_max: '15',
|
||||
})
|
||||
|
||||
const tokenJustGenerated = ref(false)
|
||||
@@ -106,6 +185,35 @@ const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
// The settings API masks the token, so the field above shows '********' rather
|
||||
// than the value. Copying that provisions a screen with the mask and every
|
||||
// kiosk call answers 404 with nothing to say why. This asks the server for the
|
||||
// real link instead, so the copy step no longer depends on catching the one
|
||||
// moment after Generate when the value is on screen.
|
||||
const link = ref('')
|
||||
const linkMessage = ref('')
|
||||
|
||||
async function copyLink () {
|
||||
linkMessage.value = ''
|
||||
link.value = ''
|
||||
try {
|
||||
const response = await api.get('/usb/kiosk/provisioning-link')
|
||||
const url = window.location.origin + withBase(response.data.data.url)
|
||||
link.value = url
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
linkMessage.value = 'Copied. Open this once on the kiosk screen.'
|
||||
} catch (clipboardError) {
|
||||
// Clipboard access is refused over plain http and in some locked-down
|
||||
// browsers. The link is shown below either way, so this is not a failure.
|
||||
linkMessage.value = 'Copy this and open it once on the kiosk screen.'
|
||||
}
|
||||
} catch (linkError) {
|
||||
linkMessage.value = linkError.response?.data?.data?.error?.message
|
||||
|| 'Could not build the setup link.'
|
||||
}
|
||||
}
|
||||
|
||||
function asBool (value) {
|
||||
return String(value).toLowerCase() === 'true' || value === true || value === 1
|
||||
}
|
||||
@@ -115,9 +223,9 @@ async function load () {
|
||||
const response = await settingsApi.list({ category: 'usb' })
|
||||
for (const row of (response.data.data || [])) {
|
||||
if (!KEYS.includes(row.key)) continue
|
||||
// The token is text (the server sends a mask once set); only the two
|
||||
// switches are booleans.
|
||||
values.value[row.key] = row.key === 'usb_kiosk_token'
|
||||
// Text settings stay text (the server sends a mask for the token once it
|
||||
// is set); only the switches are booleans.
|
||||
values.value[row.key] = TEXTKEYS.includes(row.key)
|
||||
? (row.value || '')
|
||||
: asBool(row.value)
|
||||
}
|
||||
@@ -132,10 +240,10 @@ async function save () {
|
||||
error.value = ''
|
||||
try {
|
||||
for (const key of KEYS) {
|
||||
// Saving the token as true/false replaced the real token with the word
|
||||
// 'true', and every provisioned kiosk then got 404. The server keeps the
|
||||
// stored token when it receives the mask back unchanged.
|
||||
const value = key === 'usb_kiosk_token'
|
||||
// Saving a text setting through the boolean path replaced the real token
|
||||
// with the word 'true', and every provisioned kiosk then got 404. The
|
||||
// server keeps the stored token when it receives the mask back unchanged.
|
||||
const value = TEXTKEYS.includes(key)
|
||||
? String(values.value[key] ?? '')
|
||||
: (values.value[key] ? 'true' : 'false')
|
||||
await settingsApi.update(key, value)
|
||||
|
||||
@@ -49,9 +49,23 @@ describe('USBSettings save', () => {
|
||||
usb_kiosk_enabled: 'true',
|
||||
usb_kiosk_allow_return_for_others: 'false',
|
||||
usb_kiosk_token: MASK,
|
||||
usb_kiosk_allowed_cidrs: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves the network allowlist as text, not as true/false', async () => {
|
||||
// Same trap the token fell into: a text setting sent through the boolean
|
||||
// path becomes the word 'true', and the gate then admits nobody while the
|
||||
// settings page still shows something plausible.
|
||||
const wrapper = await render([
|
||||
{ key: 'usb_kiosk_enabled', value: 'true' },
|
||||
{ key: 'usb_kiosk_allowed_cidrs', value: '192.0.2.0/24, 198.51.100.0/24' },
|
||||
])
|
||||
await wrapper.find('button.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saved().usb_kiosk_allowed_cidrs).toBe('192.0.2.0/24, 198.51.100.0/24')
|
||||
})
|
||||
|
||||
it('saves a generated token as the token itself', async () => {
|
||||
const wrapper = await render([
|
||||
{ key: 'usb_kiosk_enabled', value: 'true' },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "USB device checkout management",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"core_version": ">=0.35.0,<1.0.0",
|
||||
"api_prefix": "/api/usb",
|
||||
"default_enabled": false
|
||||
}
|
||||
@@ -121,6 +121,18 @@ class USBPlugin(BasePlugin):
|
||||
|
||||
The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns
|
||||
it on deliberately. Opening that door is a decision about the door.
|
||||
|
||||
DECLARING A KEY HERE IS HALF THE JOB. A site on an air-gapped server
|
||||
changes a setting through the USB settings page or not at all, and that
|
||||
page renders a hardcoded list - frontend/views/USBSettings.vue, its KEYS
|
||||
and TEXTKEYS arrays plus a field for each. A key declared here and absent
|
||||
there is a constant with extra steps.
|
||||
tests/test_plugin_settings_reachable.py is the gate for it, and it is
|
||||
currently RED for four of the keys below: usb_kiosk_trusted_proxies,
|
||||
usb_kiosk_allow_wide_cidrs, usb_kiosk_badge_limit_enabled and
|
||||
usb_kiosk_badge_miss_max. All four need a field on that page. The last
|
||||
two are a checkbox and a number; the first two belong beside the existing
|
||||
allowlist picker, since all three describe the same decision.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
@@ -145,10 +157,101 @@ class USBPlugin(BasePlugin):
|
||||
# Masking keys on the NAME containing 'token'.
|
||||
'valuetype': 'string',
|
||||
'category': 'usb',
|
||||
'description': 'Device token the USB kiosk sends as '
|
||||
'X-Kiosk-Token. Generate one, then open the '
|
||||
'kiosk once as /usb-kiosk?token=<value> on each '
|
||||
'screen',
|
||||
'description': 'Device token the USB kiosk sends on every '
|
||||
'call. Generate one, then use "Copy setup link" '
|
||||
'and open that link once on each screen: the '
|
||||
'link carries a single-use code, not the token, '
|
||||
'so the token never sits in a URL or a proxy '
|
||||
'log. Screens provisioned by an older '
|
||||
'/usb-kiosk?token=<value> link keep working',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allowed_cidrs',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'usb',
|
||||
'description': 'Networks allowed to use the kiosk WITHOUT the '
|
||||
'device token, as comma-separated CIDRs (for '
|
||||
'example 192.0.2.0/24). For people who open '
|
||||
'the kiosk on their own PC and never log in - '
|
||||
'provisioning a token into every browser is not '
|
||||
'workable. Empty = token only. Wall screens keep '
|
||||
'using the token either way. A range wider than '
|
||||
'/16 is refused unless usb_kiosk_allow_wide_cidrs '
|
||||
'is on, and 0.0.0.0/0 always is. This is only an '
|
||||
'admission check if the caller cannot choose the '
|
||||
'address the app sees: on Windows that means the '
|
||||
'IIS rewrite rule is live (-ClientIpSource '
|
||||
'direct), or a reverse proxy in front appends the '
|
||||
'caller to X-Forwarded-For. The installer checks '
|
||||
'both and says so. Until one holds, leave this '
|
||||
'empty and provision each screen with the token.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_trusted_proxies',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'usb',
|
||||
'description': 'Addresses or CIDRs of the reverse proxies and '
|
||||
'load balancers that sit in front of this '
|
||||
'server, comma separated. These are SUBTRACTED '
|
||||
'from the kiosk allowlist: a request that arrives '
|
||||
'wearing one of these addresses is the proxy '
|
||||
'talking about itself - it forwarded nothing, or '
|
||||
'nothing forwarded at all - so it says nothing '
|
||||
'about who the caller is and is refused rather '
|
||||
'than admitted. Set this whenever a proxy is in '
|
||||
'front, because a proxy usually sits on the very '
|
||||
'plant network the allowlist names, and would '
|
||||
'otherwise match it. The app never reads '
|
||||
'X-Forwarded-For itself; the layer in front of '
|
||||
'it already resolved the caller address.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allow_wide_cidrs',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'usb',
|
||||
'description': 'Allow a kiosk network range wider than /16 '
|
||||
'(/48 for IPv6) to be saved. Off by default '
|
||||
'because the usual reason a wide range appears '
|
||||
'is a typo, and everyone inside one is admitted '
|
||||
'to an unauthenticated write with no token and '
|
||||
'no login. Turn it on only for a site that '
|
||||
'really is one flat network that wide. '
|
||||
'0.0.0.0/0 and ::/0 stay refused either way.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_badge_limit_enabled',
|
||||
'value': 'true',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'usb',
|
||||
'description': 'Stop answering badge lookups for a caller that '
|
||||
'has scanned too many badges this site does not '
|
||||
'recognise. On by default: every kiosk call '
|
||||
'answers with the employee NAME, so an unbounded '
|
||||
'lookup is a directory anyone admitted can walk, '
|
||||
'and admission by network can mean a whole VLAN '
|
||||
'rather than one screen by the cabinet. An '
|
||||
'ordinary day at the cabinet produces no misses '
|
||||
'at all. Turn it off only for a site whose '
|
||||
'scanners misread often enough that people are '
|
||||
'being locked out mid-shift.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_badge_miss_max',
|
||||
'value': '15',
|
||||
'valuetype': 'integer',
|
||||
'category': 'usb',
|
||||
'description': 'How many unrecognised badges one caller may '
|
||||
'scan within ten minutes before the kiosk stops '
|
||||
'answering for the rest of that window. Only '
|
||||
'misses count, so this can sit well above a bad '
|
||||
'scanning day and still stop somebody walking '
|
||||
'the badge range with a loop. Blank, zero or '
|
||||
'anything that is not a number falls back to 15 '
|
||||
'- zero here would refuse the first scan of '
|
||||
'every window, not lift the limit.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allow_return_for_others',
|
||||
@@ -185,6 +288,25 @@ class USBPlugin(BasePlugin):
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
# The kiosk CIDR settings decide who may write to this site without
|
||||
# logging in, and the core settings API stores what it is given: it
|
||||
# checks valuetype and has no per-key hook a plugin can declare. These
|
||||
# are this plugin's keys, so this plugin validates them, at the save,
|
||||
# where the administrator who typed the range is still there to read
|
||||
# why it was refused.
|
||||
#
|
||||
# Guarded because init_app also runs when an operator enables this
|
||||
# plugin on a RUNNING server, and Flask refuses a setup method after
|
||||
# the first request. Symmetric with the blueprint, which the plugin
|
||||
# manager already declines to register at that point: the guard starts
|
||||
# working at the next restart, and a plugin being switched on is not a
|
||||
# moment to fail the whole load over.
|
||||
from .api.routes import validate_kiosk_settings_write
|
||||
try:
|
||||
app.before_request(validate_kiosk_settings_write)
|
||||
except Exception:
|
||||
logger.warning('USB kiosk CIDR validation is not active until this '
|
||||
'app restarts (plugin enabled after first request)')
|
||||
logger.info(f"USB plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
|
||||
Reference in New Issue
Block a user