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.
This commit is contained in:
cproudlock
2026-09-16 14:33:22 -04:00
parent 158ee0507e
commit da91a29155
13 changed files with 530 additions and 26 deletions

View File

@@ -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)

View File

@@ -68,6 +68,18 @@
</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>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
@@ -143,6 +155,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi, usersApi, supportteamsApi } from '@/api'
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
const KEYS = [
'printedparts_code_prefix',
@@ -150,6 +163,7 @@ const KEYS = [
'printedparts_default_threshold',
'printedparts_kiosk_enabled',
'printedparts_kiosk_token',
'printedparts_kiosk_allowed_cidrs',
'printedparts_unknown_badge',
'printedparts_alert_email',
'printedparts_alert_userids',
@@ -163,6 +177,7 @@ const values = ref({
printedparts_default_threshold: 5,
printedparts_kiosk_enabled: 'false',
printedparts_kiosk_token: '',
printedparts_kiosk_allowed_cidrs: '',
printedparts_unknown_badge: 'deny',
printedparts_alert_email: '',
printedparts_alert_userids: '',

View File

@@ -172,6 +172,21 @@ class PrintedpartsPlugin(BasePlugin):
'Generate one, then open the kiosk once as '
'/parts-kiosk?token=<value> on each screen',
},
{
'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',