Shut the parts kiosk behind a device token, and stop the health probe leaking

This commit is contained in:
2026-09-02 17:45:05 -04:00
parent bb733f1332
commit 2f90419ceb
8 changed files with 295 additions and 44 deletions

View File

@@ -431,11 +431,54 @@ def adjust_item(item_id: int):
return success_response(item.to_dict(), message='Stock adjusted')
# --- kiosk: UNauthenticated by decision record --------------------------------
# The take endpoint is the product's first open WRITE. The proposal's decision
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
# physically rate-limited. It can reduce stock of an active item and nothing
# else; identity comes from the badge resolved server-side, never the client.
# --- kiosk: off by default, bound to a provisioned device token ---------------
# The take endpoint is the product's only open WRITE. The original decision
# record justified that with "physically rate-limited: it serves a touch screen
# on the shop floor". Nothing in code enforced the physical part, and an HTTP
# route is reachable from anywhere the app is, so the justification did not
# survive contact with a scanner.
#
# What was actually reachable with no credential: bare digits in
# _kiosk_find_item enumerate the catalogue without knowing a barcode, a badge is
# a short numeric SSO, and this route answered 422 for an unknown badge and 200
# for a known one - an oracle for finding real ones. The write then decremented
# stock and stamped the ledger, which is the audit trail, with whichever SSO the
# caller supplied.
#
# So a badge is ATTRIBUTION, never authorization. Both kiosk routes now require
# the kiosk to be switched on for this site AND the device token an administrator
# provisioned. The read is gated too, not just the write: leaving it open would
# keep the enumeration oracle running against a kiosk that is supposedly off.
#
# The token is per-DEVICE, not per-page. A kiosk is a browser on a touch screen,
# so a token embedded in the served page would be readable by anyone who can
# load it. An admin opens the kiosk once with ?token=..., the page keeps it in
# that browser's localStorage and sends it as X-Kiosk-Token thereafter.
import hmac
# 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.
KIOSK_MAX_TAKE_QUANTITY = 25
def _kiosk_open():
"""True when this site has the kiosk on AND the caller carries its token."""
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
return False
expected = (Setting.get('printedparts_kiosk_token') or '').strip()
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
if not expected or not presented:
return False
# compare_digest, not ==: the token is a secret and == leaks its length and
# matching prefix through timing.
return hmac.compare_digest(presented, expected)
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)
def _kiosk_find_item(itemcode):
"""Resolve a scanned or typed code to an active item.
@@ -468,7 +511,9 @@ def _kiosk_find_item(itemcode):
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
def kiosk_item(itemcode):
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
"""Item summary for a scanned bin barcode. Kiosk-token gated."""
if not _kiosk_open():
return _kiosk_shut()
item = _kiosk_find_item(itemcode)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
@@ -478,7 +523,13 @@ def kiosk_item(itemcode):
@printedparts_bp.route('/kiosk/take', methods=['POST'])
def kiosk_take():
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
"""Take parts from a bin. Body: {itemcode, badge, quantity}.
The badge attributes the ledger row. It does not authorize the write - the
kiosk flag and the device token do.
"""
if not _kiosk_open():
return _kiosk_shut()
data = request.get_json() or {}
item = _kiosk_find_item(data.get('itemcode'))
@@ -487,9 +538,17 @@ def kiosk_take():
'No part matches that barcode', http_code=404)
quantity = data.get('quantity')
if not isinstance(quantity, int) or quantity < 1:
# isinstance(True, int) is True in Python, so True would have passed as a
# quantity of 1. Reject bool explicitly.
if (not isinstance(quantity, int) or isinstance(quantity, bool)
or quantity < 1):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Enter how many you are taking')
if quantity > KIOSK_MAX_TAKE_QUANTITY:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Take at most {KIOSK_MAX_TAKE_QUANTITY} at a time - see the '
f'parts team')
if quantity > item.quantityonhand:
return error_response(
ErrorCodes.VALIDATION_ERROR,
@@ -498,8 +557,14 @@ def kiosk_take():
try:
sso, name = resolve_badge(data.get('badge'))
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
except BadgeError:
# ONE message for every badge failure on the ANONYMOUS route. This is
# the only place it matters: distinguishing "malformed" from "not
# recognized" let a caller with no account sort guesses into real
# employee numbers. The gated restock/adjust handlers keep the specific
# message, because there the person is known and the detail is help.
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not accepted - see the parts team',
http_code=422)
# Print-file revision the scanned part carried: explicit field, else the

View File

@@ -92,7 +92,7 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { printedpartsApi, settingsApi } from '@/api'
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api'
import { withBase } from '@/utils/basePath'
import TouchKeypad from '@/components/TouchKeypad.vue'
@@ -116,7 +116,28 @@ const scannedRevision = ref(null)
const entryInput = ref(null)
let resetTimer = null
// Claim the device token, once, from the URL an admin used to set this screen
// up: /parts-kiosk?token=<value>. It is kept in THIS browser and stripped from
// the address bar immediately, so it does not sit in the URL for a shoulder to
// read, and does not travel in a bookmark or a screenshot. Every kiosk call
// then carries it as X-Kiosk-Token; without it the routes answer 404.
function claimTokenFromUrl() {
try {
const url = new URL(window.location.href)
const token = (url.searchParams.get('token') || '').trim()
if (!token) return
localStorage.setItem(KIOSK_TOKEN_KEY, token)
url.searchParams.delete('token')
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
} catch {
// A browser refusing localStorage is a browser this kiosk cannot be
// provisioned in. Nothing to recover here; the routes will 404 and the
// operator sees the standard error.
}
}
onMounted(async () => {
claimTokenFromUrl()
// A kiosk left running for weeks reads this once at load, which is fine: the
// label format is a property of the site's physical tags, not something that
// changes during a shift.

View File

@@ -150,6 +150,24 @@ class PrintedpartsPlugin(BasePlugin):
'category': 'printedparts',
'description': 'Default low-stock threshold for new items',
},
{
'key': 'printedparts_kiosk_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'printedparts',
'description': 'Serve the parts kiosk routes. Off by default: '
'they are the only unauthenticated write in the '
'product, so a site opts in',
},
{
'key': 'printedparts_kiosk_token',
'value': '',
'valuetype': 'secret',
'category': 'printedparts',
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
'Generate one, then open the kiosk once as '
'/parts-kiosk?token=<value> on each screen',
},
{
'key': 'printedparts_unknown_badge',
'value': 'deny',