Refuse a kiosk caller wearing an address that belongs to the path

Both kiosks admit a check-in or check-out with no login when the caller sits in
a configured network. The subtraction that makes that safe was missing.

A reverse proxy normally sits ON the plant network a site enters in the
allowlist. When the proxy forwards nothing - misconfigured, or something
bypassed it - the request arrives wearing the proxy's own address, which matches
the allowlist, while carrying no evidence whatever about who sent it. That is
the one case that has to refuse, and it was being admitted. The previous
fail-closed test only looked closed because the proxy in it happened to sit
outside the range.

Addresses that belong to the path are now subtracted before the allowlist is
consulted: loopback always, since no site's kiosk is the server itself, plus
whatever the site declares as its proxies. USB and printedparts get the same
treatment - they had diverged for no reason anybody chose.

Three more, same surface:

A range that admits everybody can no longer be saved by accident. 0.0.0.0/0 and
::/0 are refused outright, anything wider than a site plausibly needs is refused
unless an explicit override is set, and the refusal says why.

The kiosk token no longer travels in a URL. The provisioning link carries a
single-use setup code redeemed on first open, so the token stays out of browser
history, referrers and proxy logs. The route is declared public deliberately:
the secret is the code, the way a password-reset link is the secret.

The badge-to-name lookup is bounded. It answers without a login and is now
reachable from a whole network rather than one screen, so a source that keeps
missing gets refused rather than being allowed to walk the directory. On by
default, budget and kill switch are settings.

All four new settings carry controls on their settings pages. A setting that can
stop the kiosk, or widen it, must not be reachable only by hand-editing rows.

The installer's client-IP advice is corrected: it described the opposite of what
the shipped waitress flags do, and named only GE-Enforce as the thing that
breaks when the rule is absent, when the two kiosk allowlists depend on it too.
This commit is contained in:
cproudlock
2026-09-18 13:30:24 -04:00
parent 987f5c0d0f
commit 7ac1ee3077
12 changed files with 1392 additions and 63 deletions

View File

@@ -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,13 @@ 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',
@@ -161,10 +176,82 @@ class USBPlugin(BasePlugin):
'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. Requires the IIS '
'X-Forwarded-For rewrite rule to be live, or the '
'app sees the proxy address and this matches '
'nothing.',
'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',
@@ -201,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: