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
This commit is contained in:
cproudlock
2026-09-02 17:42:54 -04:00
parent 852aec5b7f
commit 744bc4341a
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