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:
cproudlock
2026-09-18 13:30:24 -04:00
parent 987f5c0d0f
commit 7ac1ee3077
12 changed files with 1392 additions and 63 deletions

View File

@@ -49,9 +49,11 @@
<input v-if="link" :value="link" type="text" class="form-control"
readonly @focus="$event.target.select()" />
<p class="field-hint">
Provision each screen once by opening
<code>/usb-kiosk?token=&lt;this value&gt;</code> on it. That browser
keeps it; the address bar does not. Turning the kiosk on is a
Provision each screen once with <strong>Copy setup link</strong>
above and open that link on the screen. The link carries a single-use
setup code, not the token, so the token never reaches an address bar,
a browser history or a proxy log. That browser keeps the token
afterwards. Turning the kiosk on is a
decision, not a credential - without this token the routes answer 404,
so nobody who merely finds the URL can move a drive and sign it with a
badge they guessed.
@@ -61,6 +63,18 @@
</p>
</div>
<div class="form-group">
<label>Reverse proxies in front of this server</label>
<NetworkAllowlistPicker v-model="values.usb_kiosk_trusted_proxies" />
<p class="field-hint">
Only fill this in when a proxy sits between the kiosk screens and this
server. A request arriving FROM one of these addresses is never
treated as a kiosk itself, so a proxy that sits inside an allowed
network below cannot admit itself. Leave empty when browsers reach
the server directly.
</p>
</div>
<div class="form-group">
<label>Networks allowed without the token</label>
<NetworkAllowlistPicker v-model="values.usb_kiosk_allowed_cidrs" />
@@ -78,6 +92,34 @@
</p>
</div>
<div class="form-group">
<label class="switch-label">
<input type="checkbox" v-model="values.usb_kiosk_allow_wide_cidrs" />
Allow an unusually wide allowed network
</label>
<p class="field-hint">
Off, a range wider than a site normally uses is refused when you save,
and 0.0.0.0/0 is refused outright. Turn this on only to enter a wide
range deliberately: every address in it can move drives with no token
and no login.
</p>
</div>
<div class="form-group">
<label class="switch-label">
<input type="checkbox" v-model="values.usb_kiosk_badge_limit_enabled" />
Limit repeated unknown badge lookups
</label>
<p class="field-hint">
The kiosk turns a badge number into an employee name without a login,
so an allowed network can otherwise be walked to enumerate staff. On,
a source that misses too many times in a row is refused for a while.
</p>
<label>Unknown badges allowed before refusing</label>
<input v-model="values.usb_kiosk_badge_miss_max" type="text"
class="form-control" placeholder="15" />
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
@@ -108,18 +150,25 @@ 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_allowed_cidrs']
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs',
'usb_kiosk_trusted_proxies', 'usb_kiosk_allow_wide_cidrs',
'usb_kiosk_badge_limit_enabled', 'usb_kiosk_badge_miss_max']
// 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 TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs',
'usb_kiosk_trusted_proxies', 'usb_kiosk_badge_miss_max']
const values = ref({
usb_kiosk_enabled: false,
usb_kiosk_allowed_cidrs: '',
usb_kiosk_allow_return_for_others: true,
usb_kiosk_token: '',
usb_kiosk_trusted_proxies: '',
usb_kiosk_allow_wide_cidrs: false,
usb_kiosk_badge_limit_enabled: true,
usb_kiosk_badge_miss_max: '15',
})
const tokenJustGenerated = ref(false)