From da91a29155ddea1d651990b1625fc8d70d628ec7 Mon Sep 17 00:00:00 2001
From: cproudlock
Date: Wed, 16 Sep 2026 14:33:22 -0400
Subject: [PATCH] Admit kiosk callers by network position, not only by device
token
The device token assumes a handful of fixed screens an administrator
provisions once. That is not how these kiosks are used: most people open the
page on their own PC, and there is no login, so per-browser provisioning would
mean touching every PC on the floor and losing it again on the next reimage or
cleared profile. A screen in that state did not look refused either - the USB
kiosk rendered a working page whose every call answered 404.
Both kiosks now admit a caller on either ground, the same shape the GE-Enforce
client endpoints already use: the per-device token, or a source address inside
an allowlist. Wall screens keep the token and are not confined to the
allowlist; everyone else is admitted by where they are on the network. Still
gated on the site's enable flag, so nothing opens by itself, and both
allowlists ship empty - token-only until a site enters its ranges.
- shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0).
On the surface because both plugins need the identical gate, and a CIDR
parser copied into two of them is where a fail-open creeps in later. They
fail closed on an empty setting, an unparseable caller address and a
malformed entry, and they read remote_addr rather than raw X-Forwarded-For -
so the IIS rewrite rule is what makes them meaningful, which the module says
at length.
- Separate settings per kiosk, usb_kiosk_allowed_cidrs and
printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same
reason the tokens are separate.
- Both settings pages get a picker listing the network plugin's subnets with
their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has
to work at a site that does not run that plugin and must not break when an
inventory row is deleted. Entries matching no subnet are shown rather than
hidden, since a stale one left by an inventory edit looks exactly like a
hand-typed range.
- USBKiosk says it was not admitted instead of pretending to work, which
PartsKiosk already did.
- The USB settings page keyed text-vs-boolean off the token alone. Adding a
second text setting to the boolean path would have saved it as the word
'true' - the bug that replaced the kiosk token and 404'd every provisioned
screen. Both load and save now key off a list, with a spec on it.
The trade, stated plainly in the admin UI: 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. Keeping
the ranges narrow is what bounds it; login is the alternative and was ruled out
because these users do not sign in.
---
.../src/components/NetworkAllowlistPicker.vue | 129 ++++++++++++++++
plugins/printedparts/api/routes.py | 30 +++-
.../frontend/views/PrintedPartsSettings.vue | 15 ++
plugins/printedparts/plugin.py | 15 ++
plugins/usb/api/routes.py | 54 +++++--
plugins/usb/frontend/views/USBKiosk.vue | 22 ++-
plugins/usb/frontend/views/USBSettings.vue | 40 ++++-
.../usb/frontend/views/usbSettings.spec.js | 14 ++
plugins/usb/plugin.py | 16 ++
shopdb/__init__.py | 12 +-
shopdb/api/__init__.py | 7 +
shopdb/utils/ipallowlist.py | 64 ++++++++
.../test_kiosk_network_allowlist.py | 138 ++++++++++++++++++
13 files changed, 530 insertions(+), 26 deletions(-)
create mode 100644 frontend/src/components/NetworkAllowlistPicker.vue
create mode 100644 shopdb/utils/ipallowlist.py
create mode 100644 tests/test_plugins/test_kiosk_network_allowlist.py
diff --git a/frontend/src/components/NetworkAllowlistPicker.vue b/frontend/src/components/NetworkAllowlistPicker.vue
new file mode 100644
index 00000000..52b5ed48
--- /dev/null
+++ b/frontend/src/components/NetworkAllowlistPicker.vue
@@ -0,0 +1,129 @@
+
+
+
Loading networks...
+
+
+
+
+
+
+ {{ unavailableMessage }}
+
+
+
+
+
+
+ Not from the network inventory: {{ extraEntries.join(', ') }}
+
+
+ Not valid CIDR and will never match: {{ invalidEntries.join(', ') }}
+
+
+
+
+
+
+
diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py
index 58dc5688..bfd1a8a6 100644
--- a/plugins/printedparts/api/routes.py
+++ b/plugins/printedparts/api/routes.py
@@ -518,16 +518,15 @@ def adjust_item(item_id: int):
import hmac
+from shopdb.api import request_ip_allowlisted
+
# 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
-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_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 +536,27 @@ 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
+ return request_ip_allowlisted(
+ request, Setting.get('printedparts_kiosk_allowed_cidrs'))
+
+
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)
diff --git a/plugins/printedparts/frontend/views/PrintedPartsSettings.vue b/plugins/printedparts/frontend/views/PrintedPartsSettings.vue
index 270f86ae..2730d0a8 100644
--- a/plugins/printedparts/frontend/views/PrintedPartsSettings.vue
+++ b/plugins/printedparts/frontend/views/PrintedPartsSettings.vue
@@ -68,6 +68,18 @@
+
+
+
+
+ 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.
+