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

@@ -55,6 +55,23 @@
</p>
</div>
<div class="form-group">
<label>Networks allowed without the token</label>
<NetworkAllowlistPicker v-model="values.usb_kiosk_allowed_cidrs" />
<p class="field-hint">
For people who open the kiosk on their own PC. A browser on one of
these networks is admitted with no token and no login, so keep this to
the shop-floor ranges rather than everything. Leave empty to require
the token from every caller. Wall screens keep using the token either
way.
</p>
<p class="field-hint token-warn">
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.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
@@ -82,12 +99,19 @@
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
'usb_kiosk_token']
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
// Keys whose value is text. Everything else is a switch stored as true/false.
// Saving a text setting through the boolean path is what replaced the kiosk
// token with the word 'true' and 404'd every provisioned screen.
const TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
const values = ref({
usb_kiosk_enabled: false,
usb_kiosk_allowed_cidrs: '',
usb_kiosk_allow_return_for_others: true,
usb_kiosk_token: '',
})
@@ -115,9 +139,9 @@ async function load () {
const response = await settingsApi.list({ category: 'usb' })
for (const row of (response.data.data || [])) {
if (!KEYS.includes(row.key)) continue
// The token is text (the server sends a mask once set); only the two
// switches are booleans.
values.value[row.key] = row.key === 'usb_kiosk_token'
// Text settings stay text (the server sends a mask for the token once it
// is set); only the switches are booleans.
values.value[row.key] = TEXTKEYS.includes(row.key)
? (row.value || '')
: asBool(row.value)
}
@@ -132,10 +156,10 @@ async function save () {
error.value = ''
try {
for (const key of KEYS) {
// Saving the token as true/false replaced the real token with the word
// 'true', and every provisioned kiosk then got 404. The server keeps the
// stored token when it receives the mask back unchanged.
const value = key === 'usb_kiosk_token'
// Saving a text setting through the boolean path replaced the real token
// with the word 'true', and every provisioned kiosk then got 404. The
// server keeps the stored token when it receives the mask back unchanged.
const value = TEXTKEYS.includes(key)
? String(values.value[key] ?? '')
: (values.value[key] ? 'true' : 'false')
await settingsApi.update(key, value)