From 7f6aaf2e646e478216eb9d7e76967ab84b5425c1 Mon Sep 17 00:00:00 2001
From: cproudlock
Date: Wed, 2 Sep 2026 18:08:34 -0400
Subject: [PATCH] Gate the USB kiosk the same way, and make an unreachable
setting a test failure
The USB kiosk had a feature flag and nothing else. A flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a drive
between checked-out and checked-in and sign it with a badge they guessed. Same
reasoning that closed the parts kiosk, smaller blast radius - which is why
Darkwing did not raise it and why it was worth asking about anyway.
Both kiosks now share one mechanism. The device token moved out of the
printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token
by URL prefix, with one localStorage key per kiosk - they are separate screens
in separate rooms, and a shared token would mean provisioning one grants the
other. claimKioskToken() is shared too, so both views take the token from a
?token= URL once and strip it from the address bar identically.
Then the part that matters more than either fix. I told you to set the new
settings in the UI, and that was impossible: every plugin settings page carries
a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled,
printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read
correctly, and rendered by no page - changeable only by writing SQL.
tests/test_plugin_settings_reachable.py now fails when any declared setting
appears nowhere in its plugin's frontend. It found seven MORE that were already
in that state, all pre-existing:
collector_corporate_ranges computers_configpolicies_enabled
computers_machinelink_alerts computers_quietreporthours
geenforce_reportstaleminutes printers_dashboardpercent
warranty_expiringdays
All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then
fixed them and deleted the ratchet - a backlog list in a new check is a way of
agreeing the check does not apply yet. There is no backlog; the check is
absolute.
Two of my own mistakes on the way, both caught by tests rather than by reading:
the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so
it called itself until the stack ran out; and the first version of the reachable
check skipped all 17 plugins because it looked for get_settings() when the
method is get_settings_defaults() - it passed by testing nothing, which is the
exact shape it exists to catch.
Suite 2336, frontend 316, build clean.
Scope: product
---
frontend/src/api/index.js | 69 ++++++++++----
.../frontend/views/CollectorSettings.vue | 93 ++++++++++++++++++-
.../frontend/views/GeEnforceSettings.vue | 35 ++++++-
.../frontend/views/PartsKiosk.vue | 24 +----
.../frontend/views/PrintedPartsSettings.vue | 61 +++++++++++-
.../frontend/views/PrinterAlertsSettings.vue | 14 ++-
plugins/usb/api/routes.py | 37 +++++++-
plugins/usb/frontend/views/USBKiosk.vue | 3 +-
plugins/usb/frontend/views/USBSettings.vue | 41 +++++++-
plugins/usb/plugin.py | 12 +++
.../frontend/views/DellWarrantySettings.vue | 17 ++++
tests/test_plugin_settings_reachable.py | 88 ++++++++++++++++++
tests/test_plugins/test_usb_kiosk.py | 64 ++++++++++---
13 files changed, 493 insertions(+), 65 deletions(-)
create mode 100644 tests/test_plugin_settings_reachable.py
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 46733ca1..7b66a215 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -11,6 +11,54 @@ const api = axios.create({
})
// Add auth token and normalize pagination params
+// Kiosk device tokens. The kiosk routes are the only ones that write without a
+// login, so each is additionally gated on a token an administrator provisioned
+// into THAT screen's browser - see the kiosk views, which claim it from a
+// ?token= URL once and strip it from the address bar. A token served inside the
+// page would be readable by anyone who could load the page, so it is per-device
+// and never shipped with the bundle.
+//
+// One entry per kiosk: the two are separate screens in separate rooms and a
+// shared token would mean provisioning one grants the other.
+export const KIOSK_TOKEN_KEYS = {
+ '/printedparts/kiosk/': 'printedparts-kiosk-token',
+ '/usb/kiosk/': 'usb-kiosk-token',
+}
+
+function kioskTokenFor(url) {
+ const path = url || ''
+ for (const [prefix, storageKey] of Object.entries(KIOSK_TOKEN_KEYS)) {
+ if (path.includes(prefix)) {
+ try { return localStorage.getItem(storageKey) || '' } catch { return '' }
+ }
+ }
+ return ''
+}
+
+// Claim a kiosk's device token, once, from the URL an admin used to set the
+// screen up. Kept in THIS browser and stripped from the address bar straight
+// away, so it does not sit there for a shoulder to read or travel in a bookmark
+// or a screenshot.
+export function claimKioskToken(storageKey) {
+ try {
+ const url = new URL(window.location.href)
+ const token = (url.searchParams.get('token') || '').trim()
+ if (!token) return
+ localStorage.setItem(storageKey, token)
+ url.searchParams.delete('token')
+ window.history.replaceState({}, '', url.pathname + url.search + url.hash)
+ } catch {
+ // A browser refusing localStorage cannot be provisioned. Nothing to
+ // recover; the routes 404 and the operator sees the standard error.
+ }
+}
+
+api.interceptors.request.use(config => {
+ const token = kioskTokenFor(config.url)
+ if (token) config.headers['X-Kiosk-Token'] = token
+ return config
+})
+
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
@@ -1501,17 +1549,6 @@ export const measuringtoolsApi = {
}
// 3D printed parts (printedparts plugin)
-// Device token for the parts kiosk, provisioned once per screen and kept in
-// that browser. Absent means the routes answer 404, which is what an
-// unprovisioned browser should see.
-export const KIOSK_TOKEN_KEY = 'printedparts-kiosk-token'
-
-function kioskHeaders() {
- let token = ''
- try { token = localStorage.getItem(KIOSK_TOKEN_KEY) || '' } catch { token = '' }
- return token ? { 'X-Kiosk-Token': token } : {}
-}
-
export const printedpartsApi = {
list(params = {}) {
return api.get('/printedparts/items', { params })
@@ -1552,17 +1589,11 @@ export const printedpartsApi = {
adjust(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
},
- // The kiosk routes are the only unauthenticated write in the product, so they
- // are gated on a per-DEVICE token rather than on the page. It lives in this
- // browser's localStorage, put there once by an admin opening the kiosk with
- // ?token=... - see PartsKiosk.vue. A token shipped inside the page would be
- // readable by anyone who could load it, which is not a gate.
kioskItem(itemcode) {
- return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`,
- { headers: kioskHeaders() })
+ return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
},
kioskTake(data) {
- return api.post('/printedparts/kiosk/take', data, { headers: kioskHeaders() })
+ return api.post('/printedparts/kiosk/take', data)
},
listFiles(printeditemid) {
return api.get(`/printedparts/items/${printeditemid}/files`)
diff --git a/plugins/computers/frontend/views/CollectorSettings.vue b/plugins/computers/frontend/views/CollectorSettings.vue
index c530c76e..46ced119 100644
--- a/plugins/computers/frontend/views/CollectorSettings.vue
+++ b/plugins/computers/frontend/views/CollectorSettings.vue
@@ -10,6 +10,50 @@
is nothing in it to find and edit.
+
+