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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user