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.
272 lines
11 KiB
Vue
272 lines
11 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>USB Devices</h2>
|
|
</div>
|
|
|
|
<div class="card form-card">
|
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<div class="form-group">
|
|
<label class="check-row">
|
|
<input type="checkbox" v-model="values.usb_kiosk_enabled" />
|
|
<span>Allow the logged-out USB kiosk</span>
|
|
</label>
|
|
<p class="field-hint">
|
|
Turns on <code>/usb-kiosk</code>, a full-screen touch page where somebody
|
|
at the drive cabinet takes and returns a drive without signing in.
|
|
It is off by default: this is a write nobody has logged in for, so it is
|
|
a decision a site makes deliberately. While it is off, every kiosk
|
|
request is refused and the page says so.
|
|
</p>
|
|
<p class="field-hint">
|
|
The kiosk can only move a drive between checked out and checked in. It
|
|
cannot add, edit or retire one. Each action is attributed to the SSO
|
|
entered on the screen, and an SSO that matches no employee is refused -
|
|
a checkout nobody can trace to a person defeats the point of tracking
|
|
it.
|
|
</p>
|
|
<p v-if="values.usb_kiosk_enabled" class="field-hint">
|
|
<a :href="withBase('/usb-kiosk')" target="_blank">Open the kiosk</a>
|
|
- also in the sidebar under Displays.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Kiosk device token</label>
|
|
<div class="token-row">
|
|
<input v-model="values.usb_kiosk_token" type="text"
|
|
class="form-control" placeholder="not set" />
|
|
<button type="button" class="btn btn-secondary" @click="generateToken">
|
|
Generate
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" @click="copyLink">
|
|
Copy setup link
|
|
</button>
|
|
</div>
|
|
<p v-if="linkMessage" class="field-hint">{{ linkMessage }}</p>
|
|
<input v-if="link" :value="link" type="text" class="form-control"
|
|
readonly @focus="$event.target.select()" />
|
|
<p class="field-hint">
|
|
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.
|
|
</p>
|
|
<p v-if="tokenJustGenerated" class="field-hint token-warn">
|
|
Copy this now and save. It is masked once stored.
|
|
</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" />
|
|
<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="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"
|
|
:disabled="!values.usb_kiosk_enabled" />
|
|
<span>Let somebody return a drive checked out to another person</span>
|
|
</label>
|
|
<p class="field-hint">
|
|
The kiosk asks first and names the holder, then records the SSO that
|
|
actually handed the drive back. Turn this off to refuse a mismatch
|
|
outright - but then a drive left behind by somebody on leave cannot be
|
|
returned at the kiosk at all, and needs a signed-in user to sort out.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import api, { 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_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',
|
|
'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)
|
|
|
|
// Generated in the browser so it never travels anywhere it did not need to.
|
|
function generateToken() {
|
|
const bytes = new Uint8Array(24)
|
|
crypto.getRandomValues(bytes)
|
|
values.value.usb_kiosk_token =
|
|
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
|
tokenJustGenerated.value = true
|
|
}
|
|
const saving = ref(false)
|
|
const message = ref('')
|
|
const error = ref('')
|
|
|
|
// The settings API masks the token, so the field above shows '********' rather
|
|
// than the value. Copying that provisions a screen with the mask and every
|
|
// kiosk call answers 404 with nothing to say why. This asks the server for the
|
|
// real link instead, so the copy step no longer depends on catching the one
|
|
// moment after Generate when the value is on screen.
|
|
const link = ref('')
|
|
const linkMessage = ref('')
|
|
|
|
async function copyLink () {
|
|
linkMessage.value = ''
|
|
link.value = ''
|
|
try {
|
|
const response = await api.get('/usb/kiosk/provisioning-link')
|
|
const url = window.location.origin + withBase(response.data.data.url)
|
|
link.value = url
|
|
try {
|
|
await navigator.clipboard.writeText(url)
|
|
linkMessage.value = 'Copied. Open this once on the kiosk screen.'
|
|
} catch (clipboardError) {
|
|
// Clipboard access is refused over plain http and in some locked-down
|
|
// browsers. The link is shown below either way, so this is not a failure.
|
|
linkMessage.value = 'Copy this and open it once on the kiosk screen.'
|
|
}
|
|
} catch (linkError) {
|
|
linkMessage.value = linkError.response?.data?.data?.error?.message
|
|
|| 'Could not build the setup link.'
|
|
}
|
|
}
|
|
|
|
function asBool (value) {
|
|
return String(value).toLowerCase() === 'true' || value === true || value === 1
|
|
}
|
|
|
|
async function load () {
|
|
try {
|
|
const response = await settingsApi.list({ category: 'usb' })
|
|
for (const row of (response.data.data || [])) {
|
|
if (!KEYS.includes(row.key)) continue
|
|
// 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)
|
|
}
|
|
} catch (loadError) {
|
|
error.value = 'Could not load USB settings.'
|
|
}
|
|
}
|
|
|
|
async function save () {
|
|
saving.value = true
|
|
message.value = ''
|
|
error.value = ''
|
|
try {
|
|
for (const key of KEYS) {
|
|
// 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)
|
|
}
|
|
message.value = 'Settings saved'
|
|
} catch (saveError) {
|
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.token-row { display: flex; gap: 8px; align-items: center; }
|
|
.token-row .form-control { flex: 1 1 auto; }
|
|
.token-warn { color: var(--warning-dark); }
|
|
|
|
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
|
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
|
|
.check-row input { width: auto; }
|
|
.form-actions { margin-top: 1rem; }
|
|
</style>
|