From 744bc4341afefa485a403aef04a73cc6d2c07eb4 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 2 Sep 2026 17:42:54 -0400 Subject: [PATCH] Shut the parts kiosk behind a device token, and stop the health probe leaking Both from the second Darkwing scan. Every claim in it was verified against the code before acting. The kiosk was the product's only unauthenticated write, justified by a decision record saying it was "physically rate-limited: it serves a touch screen on the shop floor". Nothing in code enforced the physical half, and an HTTP route is reachable from wherever the app is. What that left open: - _kiosk_find_item falls back to matching bare digits against the numeric tail of every active item, so 1, 2, 3 walk the catalogue without knowing a barcode; - a badge is a short numeric SSO, and the route answered 422 for an unknown one and 200 for a real one - an oracle for sorting guesses into employees; - the write then decremented stock and stamped the ledger, which IS the audit trail, with whatever SSO the caller supplied; - quantity was bounded only by what was on hand, so one request emptied a bin. A badge is attribution, never authorization. Both kiosk routes now need the site's kiosk flag AND a per-device token, and 404 rather than 403 without them so a caller does not learn the door is there. The READ is gated too: gating only the write would have left the enumeration oracle running against a kiosk a site had switched off. The token is per-DEVICE, not per-page, because a kiosk is a browser on a touch screen and a token served inside the page is readable by anyone who can load it. An admin opens the screen once as /parts-kiosk?token=..., the page moves it into that browser's localStorage and strips it from the address bar. Also: quantity is capped per take, JSON true no longer passes as 1 (isinstance(True, int) is True), and every badge failure on the anonymous route returns one message. The gated restock and adjust handlers keep the specific message - there the person is known and the detail is help, not an oracle. I changed the wrong one of those three handlers first; caught by the test. /api/dashboard/health formatted str(e) straight into the body of a route PUBLIC_ENDPOINTS declares open because "monitoring has no account". A pymysql OperationalError names the database account, the app host's address, the driver and the failing statement. It is logged server-side now and the body carries a coarse status. The version goes with it - it told an anonymous caller which release to look up. Status stays 200 either way: a monitor keyed on the body's status field is the contract here, and turning a degraded database into a transport failure is a separate decision from not leaking the exception. Nine tests. The three gate tests were confirmed to fail with the gate stubbed out, rather than assumed to cover it. Suite 2325, frontend 316, build clean. Scope: product --- frontend/src/api/index.js | 21 ++- plugins/printedparts/api/routes.py | 85 ++++++++-- .../frontend/views/PartsKiosk.vue | 23 ++- plugins/printedparts/plugin.py | 18 +++ shopdb/core/api/dashboard.py | 29 +++- tests/test_core/test_authz.py | 12 +- tests/test_core/test_public_read_surface.py | 3 + .../test_plugins/test_printedparts_ledger.py | 148 +++++++++++++++--- 8 files changed, 295 insertions(+), 44 deletions(-) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 57fcc970..46733ca1 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1501,6 +1501,17 @@ 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 }) @@ -1541,11 +1552,17 @@ 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)}`) + return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`, + { headers: kioskHeaders() }) }, kioskTake(data) { - return api.post('/printedparts/kiosk/take', data) + return api.post('/printedparts/kiosk/take', data, { headers: kioskHeaders() }) }, listFiles(printeditemid) { return api.get(`/printedparts/items/${printeditemid}/files`) diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 122e0c1d..682d9add 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -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/', 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 diff --git a/plugins/printedparts/frontend/views/PartsKiosk.vue b/plugins/printedparts/frontend/views/PartsKiosk.vue index 01f95b9c..d3ff5d36 100644 --- a/plugins/printedparts/frontend/views/PartsKiosk.vue +++ b/plugins/printedparts/frontend/views/PartsKiosk.vue @@ -92,7 +92,7 @@