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

@@ -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
@@ -214,7 +230,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()