Say when a kiosk screen is not set up, and hand out the setup link
Two failures from the same root: an administrator cannot see the kiosk token, and a screen that was provisioned wrongly does not say so. The settings API masks every key whose name contains 'token', so the settings page shows '********'. The real value is visible for one moment - after Generate, before Save - and an administrator who copies the field at any other time provisions a screen with the literal mask. The kiosk then answers 404, exactly as it answers a caller it has never heard of, because the route deliberately does not advertise itself. Nothing anywhere says what went wrong. - Each kiosk gains a permission-gated provisioning-link route that returns the real setup URL, token included, to somebody who already holds settings.edit. The mask stays: a token has no business in an ordinary settings list. The copy step just stops depending on catching the one moment it is on screen. Revealing it writes an audit row, since that is when the value left the database. Both settings pages get a "Copy setup link" button, which falls back to showing the link when the clipboard is refused - it is, over plain http and in locked-down browsers. - The USB kiosk asks the server on load whether this screen may be used, and says plainly when it may not. It rendered a keypad and only failed once somebody had typed their SSO, which reads as broken rather than as not set up. It cannot guess: the enabled flag is public and only says the site has a kiosk, and a caller admitted by network holds no token to look for, so the parts kiosk's localStorage check would warn exactly the callers the allowlist exists to serve. The probe is a badge-less session request - the gate runs before any badge handling, so refused is 404 and admitted is the validation error, with no lookup and nothing recorded. A test pins that ordering, because reordering it would start telling admitted screens they are not set up. Verified in a browser, not only in tests: provisioning with the mask lands on the refused screen, a correct token gets the drive list and survives a plain revisit, and a tokenless caller inside the allowlist gets straight in.
This commit is contained in:
@@ -518,7 +518,7 @@ def adjust_item(item_id: int):
|
||||
|
||||
import hmac
|
||||
|
||||
from shopdb.api import request_ip_allowlisted
|
||||
from shopdb.api import request_ip_allowlisted, audit_log
|
||||
|
||||
# Largest single take the kiosk records. A shop-floor grab is single digits; 25
|
||||
# leaves real use untouched and denies draining a bin in one request.
|
||||
@@ -557,6 +557,27 @@ def _kiosk_open():
|
||||
request, Setting.get('printedparts_kiosk_allowed_cidrs'))
|
||||
|
||||
|
||||
@printedparts_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
||||
@require_permission('settings.edit')
|
||||
def kiosk_provisioning_link():
|
||||
"""The URL that provisions a screen, token included, for an administrator.
|
||||
|
||||
Same reason as the USB kiosk: the settings API masks any key named
|
||||
'*token*', so the value is visible for one moment after Generate and an
|
||||
administrator who copies the field later provisions a screen with
|
||||
'********' and gets a silent 404. This hands the real value to somebody
|
||||
already allowed to set it, so the mask can stay.
|
||||
"""
|
||||
token = (Setting.get('printedparts_kiosk_token') or '').strip()
|
||||
if not token:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'No kiosk token set. Generate one first.')
|
||||
audit_log('viewed', 'Setting', entityname='printedparts_kiosk_token',
|
||||
details='kiosk provisioning link')
|
||||
db.session.commit()
|
||||
return success_response({'url': '/parts-kiosk?token=' + token})
|
||||
|
||||
|
||||
def _kiosk_shut():
|
||||
"""404, not 403 - a caller without the token does not learn the door exists."""
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
||||
|
||||
@@ -55,7 +55,13 @@
|
||||
<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">
|
||||
Each kiosk screen is provisioned once by opening
|
||||
<code>/parts-kiosk?token=<this value></code> on it. The browser
|
||||
@@ -156,6 +162,33 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
import api from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
// The settings API masks the token, so the field shows '********'. Copying that
|
||||
// provisions a screen with the mask and every kiosk call answers 404 with
|
||||
// nothing to say why. Ask the server for the real link instead.
|
||||
const link = ref('')
|
||||
const linkMessage = ref('')
|
||||
|
||||
async function copyLink () {
|
||||
linkMessage.value = ''
|
||||
link.value = ''
|
||||
try {
|
||||
const response = await api.get('/printedparts/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) {
|
||||
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.'
|
||||
}
|
||||
}
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
|
||||
@@ -39,7 +39,7 @@ from shopdb.api import (
|
||||
get_pagination_params,
|
||||
require_permission,
|
||||
)
|
||||
from shopdb.api import Setting, request_ip_allowlisted
|
||||
from shopdb.api import Setting, request_ip_allowlisted, audit_log, db
|
||||
|
||||
from . import selfhosted
|
||||
|
||||
@@ -815,6 +815,40 @@ def _kiosk_badge(data):
|
||||
return badge, None
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
||||
@require_permission('settings.edit')
|
||||
def kiosk_provisioning_link():
|
||||
"""The URL that provisions a screen, token included, for an administrator.
|
||||
|
||||
WHY THIS EXISTS. The settings API masks every key whose name contains
|
||||
'token', so the settings page shows the kiosk token as '********'. The real
|
||||
value is visible for exactly one moment - after Generate, before Save - and
|
||||
an administrator who copies the field at any other time provisions a screen
|
||||
with the literal string '********'. Nothing says so: the kiosk answers 404
|
||||
the same way it answers a caller it has never heard of, because the route
|
||||
deliberately does not advertise itself. That has cost real time.
|
||||
|
||||
So the copy step stops depending on catching that moment. The mask stays -
|
||||
it is right that a token does not travel in an ordinary settings list - and
|
||||
this named, permission-gated route hands the value to somebody who is
|
||||
already allowed to set it.
|
||||
|
||||
Returns a relative URL. The caller's own origin is the right base for a
|
||||
screen being provisioned from that same browser, and the server has no
|
||||
reliable idea what hostname the operator reached it by.
|
||||
"""
|
||||
token = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
if not token:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'No kiosk token set. Generate one first.')
|
||||
# Revealing a credential is worth a line in the audit log: it is the moment
|
||||
# the value left the database, and the trail should say who asked.
|
||||
audit_log('viewed', 'Setting', entityname='usb_kiosk_token',
|
||||
details='kiosk provisioning link')
|
||||
db.session.commit()
|
||||
return success_response({'url': '/usb-kiosk?token=' + token})
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
|
||||
def kiosk_device(device_id):
|
||||
"""What the kiosk shows after a device is scanned.
|
||||
|
||||
@@ -177,8 +177,33 @@ onMounted(async () => {
|
||||
// An unreachable setting is not proof it is off; let the API decide.
|
||||
disabled.value = false
|
||||
}
|
||||
if (!disabled.value) await probeAdmission()
|
||||
focusEntry()
|
||||
})
|
||||
|
||||
// ASK, rather than guess, whether this screen may use the kiosk.
|
||||
//
|
||||
// The enabled flag is public and says only that the site HAS a kiosk; whether
|
||||
// THIS caller is admitted is decided per request, and the server answers 404
|
||||
// rather than 403 so the route does not advertise itself. Without asking, the
|
||||
// page rendered a keypad and only failed once somebody had typed their SSO -
|
||||
// which reads as broken rather than as not set up.
|
||||
//
|
||||
// The parts kiosk answers the same question by checking whether its browser
|
||||
// holds a token. That test is wrong here: a PC admitted by network carries no
|
||||
// token and is perfectly entitled, so it would warn exactly the callers the
|
||||
// allowlist exists to serve. Hence a real request.
|
||||
//
|
||||
// An empty badge is deliberate. The gate runs before any badge handling, so a
|
||||
// refused caller gets 404 and an admitted one gets the badge validation error -
|
||||
// no lookup, no write, nothing recorded either way.
|
||||
async function probeAdmission () {
|
||||
try {
|
||||
await api.post('/usb/kiosk/session', { badge: '' })
|
||||
} catch (probeError) {
|
||||
if (probeError.response?.status === 404) refused.value = true
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
|
||||
function focusEntry (event) {
|
||||
|
||||
@@ -41,7 +41,13 @@
|
||||
<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 by opening
|
||||
<code>/usb-kiosk?token=<this value></code> on it. That browser
|
||||
@@ -97,7 +103,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import api, { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
|
||||
@@ -130,6 +136,35 @@ 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
|
||||
}
|
||||
|
||||
@@ -136,3 +136,69 @@ def test_usb_allowlist_does_not_open_the_parts_kiosk(usbkiosk, client):
|
||||
response = client.get('/api/printedparts/kiosk/lookup?code=ANY',
|
||||
environ_base={'REMOTE_ADDR': PLANT})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# -- what the page probes on load ---------------------------------------------
|
||||
|
||||
def test_empty_badge_probe_separates_refused_from_admitted(usbkiosk, client):
|
||||
"""The kiosk page asks the server on load whether it may be used.
|
||||
|
||||
It cannot guess: the enabled flag is public and only says the site HAS a
|
||||
kiosk, and a caller admitted by network carries no token to look for. So the
|
||||
page sends a badge-less session request and reads the status.
|
||||
|
||||
That only works while the gate runs BEFORE badge handling - refused is 404,
|
||||
admitted is the badge validation error. If someone reorders those, the page
|
||||
starts telling admitted screens they are not set up, so hold it here.
|
||||
"""
|
||||
refused = client.post('/api/usb/kiosk/session', json={'badge': ''},
|
||||
environ_base={'REMOTE_ADDR': ELSEWHERE})
|
||||
assert refused.status_code == 404
|
||||
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
admitted = client.post('/api/usb/kiosk/session', json={'badge': ''},
|
||||
environ_base={'REMOTE_ADDR': PLANT})
|
||||
assert admitted.status_code != 404
|
||||
assert admitted.status_code < 500
|
||||
|
||||
|
||||
def test_the_probe_records_nothing(usbkiosk, client):
|
||||
"""A page load must not leave a checkout, a log row, or a resolved badge."""
|
||||
from plugins.usb.models import USBCheckout
|
||||
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
client.post('/api/usb/kiosk/session', json={'badge': ''},
|
||||
environ_base={'REMOTE_ADDR': PLANT})
|
||||
assert USBCheckout.query.count() == 0
|
||||
|
||||
|
||||
# -- provisioning link --------------------------------------------------------
|
||||
# The settings API masks any key named '*token*', so the settings page shows
|
||||
# '********'. An administrator who copies that provisions a screen with the mask
|
||||
# and gets a silent 404 - the failure that cost real time on 2026-09-16. These
|
||||
# hold the way out: a named, permission-gated route that hands the real value to
|
||||
# somebody already allowed to set it.
|
||||
|
||||
def test_provisioning_link_carries_the_real_token(usbkiosk, client, auth_headers):
|
||||
response = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
url = response.get_json()['data']['url']
|
||||
assert url == '/usb-kiosk?token=a-real-token'
|
||||
assert '*' not in url
|
||||
|
||||
|
||||
def test_provisioning_link_needs_permission(usbkiosk, client):
|
||||
"""Anonymous callers cannot read the token out of this route."""
|
||||
response = client.get('/api/usb/kiosk/provisioning-link')
|
||||
assert response.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_provisioning_link_says_so_when_no_token_is_set(usbkiosk, client,
|
||||
auth_headers):
|
||||
Setting.query.filter_by(key='usb_kiosk_token').first().value = ''
|
||||
usbkiosk.session.commit()
|
||||
response = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
assert 'Generate' in response.get_json()['data']['error']['message']
|
||||
|
||||
Reference in New Issue
Block a user