Refuse a kiosk caller wearing an address that belongs to the path
Both kiosks admit a check-in or check-out with no login when the caller sits in a configured network. The subtraction that makes that safe was missing. A reverse proxy normally sits ON the plant network a site enters in the allowlist. When the proxy forwards nothing - misconfigured, or something bypassed it - the request arrives wearing the proxy's own address, which matches the allowlist, while carrying no evidence whatever about who sent it. That is the one case that has to refuse, and it was being admitted. The previous fail-closed test only looked closed because the proxy in it happened to sit outside the range. Addresses that belong to the path are now subtracted before the allowlist is consulted: loopback always, since no site's kiosk is the server itself, plus whatever the site declares as its proxies. USB and printedparts get the same treatment - they had diverged for no reason anybody chose. Three more, same surface: A range that admits everybody can no longer be saved by accident. 0.0.0.0/0 and ::/0 are refused outright, anything wider than a site plausibly needs is refused unless an explicit override is set, and the refusal says why. The kiosk token no longer travels in a URL. The provisioning link carries a single-use setup code redeemed on first open, so the token stays out of browser history, referrers and proxy logs. The route is declared public deliberately: the secret is the code, the way a password-reset link is the secret. The badge-to-name lookup is bounded. It answers without a login and is now reachable from a whole network rather than one screen, so a source that keeps missing gets refused rather than being allowed to walk the directory. On by default, budget and kill switch are settings. All four new settings carry controls on their settings pages. A setting that can stop the kiosk, or widen it, must not be reachable only by hand-editing rows. The installer's client-IP advice is corrected: it described the opposite of what the shipped waitress flags do, and named only GE-Enforce as the thing that breaks when the rule is absent, when the two kiosk allowlists depend on it too.
This commit is contained in:
@@ -124,8 +124,11 @@ param(
|
||||
# the proxy, so applying it would DESTROY the real client IP.
|
||||
#
|
||||
# Without one or the other, IIS sends no X-Forwarded-For at all and every
|
||||
# client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard
|
||||
# visitor-location lookup and per-host login rate limiting all break quietly.
|
||||
# client reads as 127.0.0.1: the GE-Enforce IP allowlist, BOTH logged-out
|
||||
# kiosk allowlists (usb_kiosk_allowed_cidrs, printedparts_kiosk_allowed_cidrs
|
||||
# - these admit writes with no login), the dashboard visitor-location lookup
|
||||
# and per-host login rate limiting all break quietly. Stage 4 verifies this
|
||||
# in both modes; see the client-IP check there.
|
||||
[ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct',
|
||||
# Load the shared vendor/model catalog during stage 3. Offered by the
|
||||
# wizard rather than assumed: a site that machines nothing does not want
|
||||
@@ -2500,27 +2503,71 @@ public class ShopdbSmokeTestCertPolicy : ICertificatePolicy {
|
||||
}
|
||||
}
|
||||
|
||||
# Is the client-IP rule actually in effect? It is the control that makes
|
||||
# remote_addr trustworthy: without it IIS forwards whatever X-Forwarded-For
|
||||
# the CALLER sent, waitress trusts it because it arrives from loopback, and
|
||||
# the GE-Enforce IP allowlist becomes bypassable from anywhere on the network.
|
||||
# It does not fail closed. Verified here rather than assumed, because the
|
||||
# failure is silent and looks exactly like a working server.
|
||||
# Is the client-IP control actually in effect? Something has to make the
|
||||
# address the app sees the CALLER's: waitress is launched with
|
||||
# --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for, and
|
||||
# HttpPlatformHandler always connects from loopback, so waitress ALWAYS
|
||||
# believes the X-Forwarded-For it is handed. Whether that header is worth
|
||||
# believing is decided in front of it, and nothing fails closed if it is not.
|
||||
#
|
||||
# THREE allowlists now ride on this, not one: the GE-Enforce client
|
||||
# allowlist and the two kiosk allowlists (usb_kiosk_allowed_cidrs,
|
||||
# printedparts_kiosk_allowed_cidrs), which admit UNAUTHENTICATED writes by
|
||||
# network position. Naming only GE-Enforce here sent people to the wrong
|
||||
# place - and left the kiosks out of the decision entirely.
|
||||
#
|
||||
# Checked in BOTH modes. It used to run only under -ClientIpSource direct,
|
||||
# so the proxy deployment - the one where the header really does arrive from
|
||||
# outside - had nothing verified at all.
|
||||
$liveCfg = Join-Path $AppRoot 'web.config'
|
||||
$cfgText = if (Test-Path $liveCfg) { Get-Content $liveCfg -Raw } else { '' }
|
||||
$ruleLive = ($cfgText -match '<rewrite>') -and ($cfgText -notmatch 'SHOPDB-CLIENTIP-BEGIN')
|
||||
$moduleThere = Test-Path (Join-Path $env:windir 'system32\inetsrv\rewrite.dll')
|
||||
if ($ClientIpSource -eq 'direct') {
|
||||
$liveCfg = Join-Path $AppRoot 'web.config'
|
||||
$cfgText = if (Test-Path $liveCfg) { Get-Content $liveCfg -Raw } else { '' }
|
||||
$ruleLive = ($cfgText -match '<rewrite>') -and ($cfgText -notmatch 'SHOPDB-CLIENTIP-BEGIN')
|
||||
$moduleThere = Test-Path (Join-Path $env:windir 'system32\inetsrv\rewrite.dll')
|
||||
if ($ruleLive -and $moduleThere) {
|
||||
Write-Log 'client-IP rule is live; remote_addr is trustworthy' 'OK'
|
||||
} else {
|
||||
Write-Log 'CLIENT-IP RULE IS NOT IN EFFECT' 'WARN'
|
||||
if (-not $moduleThere) { Write-Log ' URL Rewrite module is not installed' 'WARN' }
|
||||
if (-not $ruleLive) { Write-Log ' web.config does not have an active <rewrite> block' 'WARN' }
|
||||
Write-Log ' every client will be recorded as 127.0.0.1, and the GE-Enforce IP' 'WARN'
|
||||
Write-Log ' allowlist is BYPASSABLE - a caller can send its own X-Forwarded-For' 'WARN'
|
||||
Write-Log ' re-run with -ClientIpSource direct, or use token authentication' 'WARN'
|
||||
Write-Log ' a caller can send its own X-Forwarded-For and be believed, so EVERY' 'WARN'
|
||||
Write-Log ' address-based control is BYPASSABLE from anywhere on the network:' 'WARN'
|
||||
Write-Log ' - the GE-Enforce client IP allowlist' 'WARN'
|
||||
Write-Log ' - usb_kiosk_allowed_cidrs (logged-out USB kiosk writes)' 'WARN'
|
||||
Write-Log ' - printedparts_kiosk_allowed_cidrs (logged-out parts kiosk writes)' 'WARN'
|
||||
Write-Log ' and with no header at all every client is recorded as 127.0.0.1.' 'WARN'
|
||||
Write-Log ' the USB kiosk refuses a loopback caller outright rather than' 'WARN'
|
||||
Write-Log ' admitting one, so that case reads as "not set up", not as access.' 'WARN'
|
||||
Write-Log ' install URL Rewrite and enable the SHOPDB-CLIENTIP block, or leave the' 'WARN'
|
||||
Write-Log ' two kiosk allowlists EMPTY and provision each screen with its token' 'WARN'
|
||||
}
|
||||
} else {
|
||||
# Proxy mode. The rule must be OFF here - it would overwrite the real
|
||||
# client with the proxy's address - and the thing that has to be true
|
||||
# instead is about the proxy, which this script cannot reach out and
|
||||
# test. So: check what can be checked, and state the rest plainly.
|
||||
if ($ruleLive) {
|
||||
Write-Log 'web.config OVERWRITES X-Forwarded-For but -ClientIpSource is proxy' 'WARN'
|
||||
Write-Log ' every request will be attributed to the proxy, not the caller:' 'WARN'
|
||||
Write-Log ' the GE-Enforce and kiosk allowlists then match nothing at all' 'WARN'
|
||||
Write-Log ' remove the <rewrite> block from web.config' 'WARN'
|
||||
} else {
|
||||
Write-Log 'client IP comes from the proxy; no rewrite rule in web.config' 'OK'
|
||||
}
|
||||
Write-Log 'PROXY MODE: two things this installer cannot verify for you' 'WARN'
|
||||
Write-Log ' 1. the proxy must APPEND the caller to X-Forwarded-For (ARR does).' 'WARN'
|
||||
Write-Log ' waitress takes the LAST hop and puts it in REMOTE_ADDR, so a proxy' 'WARN'
|
||||
Write-Log ' that passes a caller-sent header through unchanged makes every' 'WARN'
|
||||
Write-Log ' address control bypassable from anywhere that can reach the proxy.' 'WARN'
|
||||
Write-Log ' 2. set usb_kiosk_trusted_proxies (Settings > USB) to where the proxy' 'WARN'
|
||||
Write-Log ' is, BEFORE filling in usb_kiosk_allowed_cidrs. The app does not' 'WARN'
|
||||
Write-Log ' read forwarding headers itself - waitress already promoted the' 'WARN'
|
||||
Write-Log ' caller into REMOTE_ADDR, so the allowlist matches the CALLER, not' 'WARN'
|
||||
Write-Log ' the proxy. What it is for is the opposite case: a request that' 'WARN'
|
||||
Write-Log ' arrives wearing the PROXY address, because the proxy forwarded' 'WARN'
|
||||
Write-Log ' nothing. A proxy normally sits on the same plant network the' 'WARN'
|
||||
Write-Log ' allowlist names, so that request would otherwise be admitted on no' 'WARN'
|
||||
Write-Log ' evidence at all. Naming the proxy here subtracts it and fails closed.' 'WARN'
|
||||
}
|
||||
|
||||
# The self-hosted API documentation. It is the thing an operator - or the
|
||||
|
||||
@@ -80,7 +80,7 @@ import re
|
||||
from flask import current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import Setting
|
||||
from shopdb.api import Setting, client_ip, ip_in_cidrs
|
||||
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
||||
IMAGE_URL_PREFIX = '/api/printedparts/image/'
|
||||
@@ -518,12 +518,36 @@ def adjust_item(item_id: int):
|
||||
|
||||
import hmac
|
||||
|
||||
from shopdb.api import request_ip_allowlisted, audit_log
|
||||
from shopdb.api import 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.
|
||||
KIOSK_MAX_TAKE_QUANTITY = 25
|
||||
|
||||
# Addresses that belong to the PATH, not to a caller. Subtracted from the
|
||||
# allowlist before it is consulted.
|
||||
#
|
||||
# WHY, and it is the half that was missing here: a reverse proxy normally sits
|
||||
# ON the network a site enters in printedparts_kiosk_allowed_cidrs. A request
|
||||
# that arrives wearing the proxy's own address - because the proxy forwarded
|
||||
# nothing, or because nothing forwarded at all - therefore MATCHED the allowlist
|
||||
# and was admitted while carrying no evidence about who actually sent it. Being
|
||||
# inside the allowlist is not enough; the address also has to be one a client
|
||||
# could plausibly have had.
|
||||
#
|
||||
# Loopback is never a client at any site, so it needs no configuration. The USB
|
||||
# kiosk closed this on 2026-09-18 and this one did not, which is the only reason
|
||||
# the two read differently.
|
||||
PARTS_KIOSK_TRUSTED_PROXIES = 'printedparts_kiosk_trusted_proxies'
|
||||
PARTS_KIOSK_NEVER_A_CLIENT = '127.0.0.0/8, ::1/128'
|
||||
|
||||
|
||||
def _kiosk_address_is_infrastructure(address):
|
||||
"""True when this address belongs to the path rather than to a caller."""
|
||||
if ip_in_cidrs(address, PARTS_KIOSK_NEVER_A_CLIENT):
|
||||
return True
|
||||
return ip_in_cidrs(address, Setting.get(PARTS_KIOSK_TRUSTED_PROXIES))
|
||||
|
||||
|
||||
def _kiosk_token_presented():
|
||||
"""True when the caller carries this kiosk's device token."""
|
||||
@@ -553,8 +577,10 @@ def _kiosk_open():
|
||||
return False
|
||||
if _kiosk_token_presented():
|
||||
return True
|
||||
return request_ip_allowlisted(
|
||||
request, Setting.get('printedparts_kiosk_allowed_cidrs'))
|
||||
address = client_ip(request)
|
||||
if _kiosk_address_is_infrastructure(address):
|
||||
return False
|
||||
return ip_in_cidrs(address, Setting.get('printedparts_kiosk_allowed_cidrs'))
|
||||
|
||||
|
||||
@printedparts_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
||||
|
||||
@@ -86,6 +86,18 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Reverse proxies in front of this server</label>
|
||||
<NetworkAllowlistPicker v-model="values.printedparts_kiosk_trusted_proxies" />
|
||||
<p class="field-hint">
|
||||
Only needed when a proxy sits between the kiosk and this server. A
|
||||
request arriving FROM one of these addresses is never treated as a
|
||||
kiosk, so a proxy inside an allowed network above cannot admit itself.
|
||||
Loopback is always excluded and needs no entry. Leave empty when
|
||||
browsers reach this server directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
@@ -197,6 +209,7 @@ const KEYS = [
|
||||
'printedparts_kiosk_enabled',
|
||||
'printedparts_kiosk_token',
|
||||
'printedparts_kiosk_allowed_cidrs',
|
||||
'printedparts_kiosk_trusted_proxies',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
@@ -211,6 +224,7 @@ const values = ref({
|
||||
printedparts_kiosk_enabled: 'false',
|
||||
printedparts_kiosk_token: '',
|
||||
printedparts_kiosk_allowed_cidrs: '',
|
||||
printedparts_kiosk_trusted_proxies: '',
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
|
||||
@@ -172,6 +172,21 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'Generate one, then open the kiosk once as '
|
||||
'/parts-kiosk?token=<value> on each screen',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_kiosk_trusted_proxies',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printedparts',
|
||||
'description': 'Addresses or CIDRs of the reverse proxies and '
|
||||
'load balancers in front of this server, comma '
|
||||
'separated. SUBTRACTED from the kiosk allowlist '
|
||||
'below: a request wearing one of these addresses '
|
||||
'is the proxy talking about itself, so it is '
|
||||
'refused rather than admitted by a range that '
|
||||
'happens to contain the proxy. Loopback is '
|
||||
'always subtracted and needs no entry. Leave '
|
||||
'empty when browsers reach this server directly.',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_kiosk_allowed_cidrs',
|
||||
'value': '',
|
||||
|
||||
@@ -20,13 +20,17 @@ cmmc_usb schema used here:
|
||||
'YYYY-MM-DD HH:MM:SS' string; scanned_viruses/sanitized are '0'|'1'|null.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import hmac
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask import Blueprint, current_app, redirect, request, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
@@ -39,7 +43,7 @@ from shopdb.api import (
|
||||
get_pagination_params,
|
||||
require_permission,
|
||||
)
|
||||
from shopdb.api import Setting, request_ip_allowlisted, audit_log, db
|
||||
from shopdb.api import Setting, audit_log, cache, client_ip, db, ip_in_cidrs
|
||||
|
||||
from . import selfhosted
|
||||
|
||||
@@ -743,23 +747,110 @@ def _kiosk_enabled():
|
||||
return _kiosk_setting('usb_kiosk_enabled', False)
|
||||
|
||||
|
||||
# The device token also travels as a cookie, set by the setup exchange below,
|
||||
# so it never has to sit in a URL to reach a screen.
|
||||
KIOSK_TOKEN_COOKIE = 'usbkiosktoken'
|
||||
|
||||
# Where this site's reverse proxies are, as CIDRs. NOT a source of addresses -
|
||||
# see the block below - but a subtraction from the allowlist: a request wearing
|
||||
# one of these addresses is infrastructure talking, never a caller.
|
||||
KIOSK_TRUSTED_PROXIES = 'usb_kiosk_trusted_proxies'
|
||||
|
||||
# Addresses that are never a kiosk client at any site, whatever was typed into
|
||||
# the allowlist. Loopback is the shipped Windows stack with no forwarding header
|
||||
# at all: HttpPlatformHandler connects from 127.0.0.1, waitress has nothing to
|
||||
# promote, and every caller then arrives wearing the app server's own address.
|
||||
KIOSK_NEVER_A_CLIENT = '127.0.0.0/8, ::1/128'
|
||||
|
||||
|
||||
def _kiosk_token_presented():
|
||||
"""True when the caller presents the configured device token.
|
||||
|
||||
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
|
||||
a token served inside the page would be readable by anyone who could load
|
||||
it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
|
||||
it in that browser and sends it as X-Kiosk-Token.
|
||||
it.
|
||||
|
||||
Two carriers, one value. A screen provisioned the old way keeps it in
|
||||
localStorage and sends X-Kiosk-Token; a screen provisioned through
|
||||
kiosk_setup holds it in an HttpOnly cookie that the browser sends on its
|
||||
own. Accepting both is what lets the URL-borne token go away without
|
||||
reprovisioning every screen already on the floor.
|
||||
"""
|
||||
expected = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
if not expected or not presented:
|
||||
if not expected:
|
||||
return False
|
||||
presented = ((request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
or (request.cookies.get(KIOSK_TOKEN_COOKIE) or '').strip())
|
||||
if not presented:
|
||||
return False
|
||||
# compare_digest, not ==: == leaks the token's length and matching prefix
|
||||
# through timing.
|
||||
return hmac.compare_digest(presented, expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The address a caller is admitted on, traced through the stack that ships
|
||||
# ---------------------------------------------------------------------------
|
||||
# IIS -> HttpPlatformHandler -> waitress on 127.0.0.1 -> this app, and waitress
|
||||
# is launched with --trusted-proxy=127.0.0.1
|
||||
# --trusted-proxy-headers=x-forwarded-for --trusted-proxy-count=1
|
||||
# (deploy/windows/web.config). HttpPlatformHandler ALWAYS connects over
|
||||
# loopback, so waitress always sees its configured trusted proxy as the peer: it
|
||||
# takes the LAST X-Forwarded-For hop, writes it into REMOTE_ADDR, and truncates
|
||||
# the environ header to that one hop, all before Flask is entered.
|
||||
#
|
||||
# So request.remote_addr IS the forwarded address by the time anything here
|
||||
# runs, and X-Forwarded-For carries a single entry equal to it. That is why the
|
||||
# version of this code that walked the header could not work on the Windows
|
||||
# deployment: it looked for the peer inside usb_kiosk_trusted_proxies before it
|
||||
# would read the header, and the peer is never the proxy any more - waitress
|
||||
# already replaced it with whatever the proxy forwarded. The branch could not
|
||||
# fire, and the parsing under it was inert.
|
||||
#
|
||||
# THIS APP TAKES THE WSGI LAYER'S WORD, and the app-level parsing is gone. Not
|
||||
# because that word is trustworthy in itself, but because Python is the wrong
|
||||
# place to adjudicate it: the proxy's own address is destroyed before Flask is
|
||||
# reached, so nothing here can tell a hop IIS wrote from one a caller sent.
|
||||
# Whether the address is real is settled in front of waitress, and only there:
|
||||
# -ClientIpSource direct: the IIS rewrite rule OVERWRITES X-Forwarded-For with
|
||||
# IIS's own REMOTE_ADDR, so a caller-sent header is destroyed on arrival.
|
||||
# -ClientIpSource proxy: the reverse proxy APPENDS the caller, so the last hop
|
||||
# is the address that proxy itself observed.
|
||||
# Under either, a client-sent header cannot choose the value waitress promotes.
|
||||
# The installer checks this in both modes and, when it does not hold, says to
|
||||
# leave the kiosk allowlist empty (deploy/windows/installer/shopdb-install.ps1).
|
||||
#
|
||||
# Reading request.headers['X-Forwarded-For'] here would undo all of it: on a box
|
||||
# whose front end does not overwrite, that header is the caller's to invent, and
|
||||
# the kiosk would admit on a value the caller's network position does not
|
||||
# support. So it is not read. Anywhere in this file.
|
||||
|
||||
|
||||
def _kiosk_client_ip():
|
||||
"""The caller address, as the layer in front of Flask resolved it."""
|
||||
return client_ip(request)
|
||||
|
||||
|
||||
def _kiosk_address_is_infrastructure(address):
|
||||
"""True when this address belongs to the path, not to a caller.
|
||||
|
||||
The fail-closed half of network admission, and the half that was missing. A
|
||||
reverse proxy normally sits ON the plant network a site enters in
|
||||
usb_kiosk_allowed_cidrs. A request that arrives wearing the proxy's own
|
||||
address - because the proxy forwarded nothing, or because nothing forwarded
|
||||
at all - therefore MATCHED the allowlist and was admitted, while carrying no
|
||||
evidence whatever about who sent it. That is the one case that has to
|
||||
refuse.
|
||||
|
||||
Being inside the allowlist is not enough on its own: the address also has to
|
||||
be one a client could plausibly have had. The previous version only looked
|
||||
closed because the proxy in its test happened to sit outside the allowlist.
|
||||
"""
|
||||
if ip_in_cidrs(address, KIOSK_NEVER_A_CLIENT):
|
||||
return True
|
||||
return ip_in_cidrs(address, Setting.get(KIOSK_TRUSTED_PROXIES))
|
||||
|
||||
|
||||
def _kiosk_ip_allowlisted():
|
||||
"""True when the caller sits in the configured kiosk network allowlist.
|
||||
|
||||
@@ -770,11 +861,14 @@ def _kiosk_ip_allowlisted():
|
||||
cleared profile. Network position is what those callers actually have in
|
||||
common.
|
||||
|
||||
Fail-closed behaviour and the IIS rewrite-rule dependency that makes the
|
||||
caller address trustworthy are documented on request_ip_allowlisted.
|
||||
Fail-closed behaviour is documented on ip_in_cidrs; where the caller address
|
||||
comes from is on _kiosk_client_ip, and which addresses can never be a caller
|
||||
at all is on _kiosk_address_is_infrastructure.
|
||||
"""
|
||||
return request_ip_allowlisted(
|
||||
request, Setting.get('usb_kiosk_allowed_cidrs'))
|
||||
address = _kiosk_client_ip()
|
||||
if _kiosk_address_is_infrastructure(address):
|
||||
return False
|
||||
return ip_in_cidrs(address, Setting.get('usb_kiosk_allowed_cidrs'))
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
@@ -815,6 +909,298 @@ def _kiosk_badge(data):
|
||||
return badge, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What a site may store in a kiosk CIDR list
|
||||
# ---------------------------------------------------------------------------
|
||||
# These two settings decide who may write without logging in, and the settings
|
||||
# API stored whatever was typed: it checks valuetype and nothing else, and has
|
||||
# no per-key hook a plugin can declare. So '0.0.0.0/0' saved cleanly and turned
|
||||
# the kiosk into an open write for anything that could reach the server.
|
||||
#
|
||||
# Checked at the SAVE (see validate_kiosk_settings_write, registered by the
|
||||
# plugin), because that is the only moment an administrator finds out. Refusing
|
||||
# a range at admission time instead would look exactly like the setting quietly
|
||||
# not working.
|
||||
|
||||
KIOSK_CIDR_KEYS = ('usb_kiosk_allowed_cidrs', 'usb_kiosk_trusted_proxies')
|
||||
KIOSK_WIDE_OVERRIDE = 'usb_kiosk_allow_wide_cidrs'
|
||||
# A /16 is already 65,536 addresses. Anything wider is a claim about the site
|
||||
# that a site should have to make on purpose, not a typo that widens a door.
|
||||
KIOSK_MIN_PREFIX = {4: 16, 6: 48}
|
||||
|
||||
|
||||
def kiosk_cidr_problem(raw, allowwide=False):
|
||||
"""Why this CIDR list may not be stored, or None when it may.
|
||||
|
||||
The message names the entry and says what it would mean, because the person
|
||||
reading it is fixing a range on a settings page, not debugging a validator.
|
||||
"""
|
||||
for part in (raw or '').split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.ip_network(part, strict=False)
|
||||
except ValueError:
|
||||
return ('"{0}" is not a network. Use address/prefix, for example '
|
||||
'192.0.2.0/24.'.format(part))
|
||||
if network.prefixlen == 0:
|
||||
return ('"{0}" is every address there is. The kiosk writes without '
|
||||
'a login, so this would let anything that can reach the '
|
||||
'server move a drive against a badge it guessed. Name the '
|
||||
'shop-floor ranges instead.'.format(part))
|
||||
floor = KIOSK_MIN_PREFIX[network.version]
|
||||
if network.prefixlen < floor and not allowwide:
|
||||
return ('"{0}" covers {1} addresses, which is wider than a site '
|
||||
'range (/{2} or narrower). Everyone inside it is admitted '
|
||||
'with no token and no login. Narrow it, or turn on "{3}" '
|
||||
'if this site really is one flat network that '
|
||||
'wide.'.format(part, network.num_addresses, floor,
|
||||
KIOSK_WIDE_OVERRIDE))
|
||||
return None
|
||||
|
||||
|
||||
# Both settings routes are gated on this one permission. Named here because the
|
||||
# guard below has to apply the SAME gate the view would, and a guard that
|
||||
# guessed a weaker one would be the hole it is meant to close.
|
||||
SETTINGS_WRITE_PERMISSION = 'settings.edit'
|
||||
|
||||
|
||||
def _settings_write_refusal():
|
||||
"""The view's own 401/403 for this caller, or None when it may write.
|
||||
|
||||
A before_request runs ahead of the view, so it runs ahead of
|
||||
@require_permission too. That ordering leaked the validation rules: an
|
||||
anonymous PUT of '0.0.0.0/0' was answered 400 with the reason, while an
|
||||
anonymous PUT of a sane range fell through to the view and was answered 401.
|
||||
The difference is an oracle - anyone who could reach the app could walk the
|
||||
allowlist rules without ever holding an account.
|
||||
|
||||
So authentication decides first, by running the same decorator the view
|
||||
carries rather than a local imitation of it. Guest grants, scoped personal
|
||||
tokens and the admin bypass therefore behave identically here and there, and
|
||||
the response an unauthorized caller gets is the one it would have got if
|
||||
this guard did not exist.
|
||||
"""
|
||||
@require_permission(SETTINGS_WRITE_PERMISSION)
|
||||
def permitted():
|
||||
return None
|
||||
|
||||
return permitted()
|
||||
|
||||
|
||||
def validate_kiosk_settings_write():
|
||||
"""before_request guard on the core settings save, for the kiosk CIDR keys.
|
||||
|
||||
Registered app-wide by the plugin (init_app): the keys belong to this
|
||||
plugin, so this plugin is what knows a bad value, and returning a response
|
||||
from before_request answers the save without performing it.
|
||||
"""
|
||||
if request.endpoint not in ('settings.update_setting',
|
||||
'settings.create_setting'):
|
||||
return None
|
||||
data = request.get_json(silent=True) or {}
|
||||
if request.endpoint == 'settings.update_setting':
|
||||
key = (request.view_args or {}).get('key')
|
||||
else:
|
||||
key = data.get('key')
|
||||
if key not in KIOSK_CIDR_KEYS or 'value' not in data:
|
||||
return None
|
||||
refusal = _settings_write_refusal()
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
# _kiosk_setting, not bool(Setting.get(...)): a row stored as a string holds
|
||||
# the word 'false', and bool('false') is True - which would turn the
|
||||
# override on for a site that had just turned it off.
|
||||
problem = kiosk_cidr_problem(str(data.get('value') or ''),
|
||||
_kiosk_setting(KIOSK_WIDE_OVERRIDE, False))
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Badge lookups: a bounded number of misses per caller
|
||||
# ---------------------------------------------------------------------------
|
||||
# Every kiosk call that takes a badge answers with the employee's NAME, which is
|
||||
# what makes the kiosk usable and also makes it a lookup table: a badge is a
|
||||
# short number, so a caller who is admitted can walk it. Admission used to mean
|
||||
# one screen by the cabinet; with the network allowlist it can mean a whole
|
||||
# VLAN, and that changes what the lookup is worth to somebody with a loop.
|
||||
#
|
||||
# Only MISSES are counted. An ordinary day at the cabinet produces none - a miss
|
||||
# is a scan of something this site has never heard of - so the budget can sit
|
||||
# well above a bad scanning day and still stop enumeration inside a minute.
|
||||
# Same fixed-window cache shape as the login limiter in shopdb.core.api.auth:
|
||||
# under SimpleCache the counter is per-process, which is defence in depth rather
|
||||
# than a hard quota.
|
||||
|
||||
BADGE_MISS_MAX = 15
|
||||
BADGE_MISS_WINDOW_SECONDS = 600
|
||||
|
||||
# The two of those an operator would ever reach for, as settings. They were
|
||||
# app-config only, which on an air-gapped server means hand-editing
|
||||
# instance/config.py and restarting IIS - so in practice the limiter had no off
|
||||
# switch and no dial at all, and the one lever anybody had was to turn the whole
|
||||
# kiosk off. Both default to exactly what the constants above did.
|
||||
#
|
||||
# The WINDOW deliberately stays app-config. It is the shape of the limiter, not
|
||||
# a number a site tunes; a budget expressed per ten minutes is the thing being
|
||||
# decided, and offering two interacting dials invites a combination nobody
|
||||
# meant.
|
||||
KIOSK_BADGE_LIMIT_ENABLED = 'usb_kiosk_badge_limit_enabled'
|
||||
KIOSK_BADGE_MISS_MAX = 'usb_kiosk_badge_miss_max'
|
||||
|
||||
|
||||
def _kiosk_int_setting(key, default):
|
||||
"""A positive whole number from a setting, else default.
|
||||
|
||||
A settings page stores text, so a blank, a word or a pasted range can all
|
||||
arrive here. Any of those has to land on the shipped budget rather than on
|
||||
zero, because zero is not "no limit" in this counter - it refuses every
|
||||
badge lookup the site has from the first scan of the window.
|
||||
"""
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
raw = row.value if row is not None and row.value is not None else ''
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def _badge_miss_budget():
|
||||
# Config first so instance/config.py and the test fixtures keep overriding,
|
||||
# then the setting, then the shipped number.
|
||||
maxmisses = current_app.config.get('USB_KIOSK_BADGE_MISS_MAX')
|
||||
if maxmisses is None:
|
||||
maxmisses = _kiosk_int_setting(KIOSK_BADGE_MISS_MAX, BADGE_MISS_MAX)
|
||||
return (current_app.config.get('USB_KIOSK_BADGE_WINDOW_SECONDS',
|
||||
BADGE_MISS_WINDOW_SECONDS),
|
||||
maxmisses)
|
||||
|
||||
|
||||
def _badge_miss_key(window):
|
||||
bucket = int(time.time() // window) if window > 0 else 0
|
||||
return 'usbkioskbadgemiss:{0}:{1}'.format(
|
||||
_kiosk_client_ip() or 'unknown', bucket)
|
||||
|
||||
|
||||
def _badge_limit_enabled():
|
||||
"""On everywhere but a test run, where the shared cache would bleed.
|
||||
|
||||
The counter lives in the process-wide cache, which outlives a test and is
|
||||
shared by whatever else lands on the same xdist worker - so misses spent in
|
||||
one module surface as an unrelated 429 in another, and which modules share a
|
||||
worker moves around. AUTH_RATELIMIT_ENABLED and the endpoints enrollment
|
||||
limiter are switched off in TestingConfig for exactly that reason; this one
|
||||
is younger than that file and defaults itself the same way.
|
||||
|
||||
Order: an explicit app config value (instance/config.py, and the fixtures in
|
||||
the test suite) wins, then TESTING, then the site setting, which ships on.
|
||||
A site turning this off is a real decision - the kiosk answers with an
|
||||
employee NAME, so an unbounded badge lookup is a directory anyone admitted
|
||||
can walk - which is why it is reachable rather than buried in a config file
|
||||
nobody on an air-gapped box can edit without a maintenance window.
|
||||
"""
|
||||
override = current_app.config.get('USB_KIOSK_BADGE_LIMIT_ENABLED')
|
||||
if override is not None:
|
||||
return bool(override)
|
||||
if current_app.config.get('TESTING', False):
|
||||
return False
|
||||
return _kiosk_setting(KIOSK_BADGE_LIMIT_ENABLED, True)
|
||||
|
||||
|
||||
def badge_lookups_exhausted():
|
||||
"""True when this caller has spent its budget of unrecognised badges."""
|
||||
if not _badge_limit_enabled():
|
||||
return False
|
||||
window, maxmisses = _badge_miss_budget()
|
||||
return (cache.get(_badge_miss_key(window)) or 0) >= maxmisses
|
||||
|
||||
|
||||
def record_badge_miss():
|
||||
"""Count one unrecognised scan against this caller.
|
||||
|
||||
Called from both directory modes - the raw-SQL path below and the
|
||||
self-hosted one in selfhosted.py - at the point where a scanned badge
|
||||
resolves to nobody. Holder-name lookups do not come through here: those are
|
||||
the kiosk naming a drive's holder, not a caller guessing.
|
||||
"""
|
||||
if not _badge_limit_enabled():
|
||||
return
|
||||
window, maxmisses = _badge_miss_budget()
|
||||
key = _badge_miss_key(window)
|
||||
count = (cache.get(key) or 0) + 1
|
||||
cache.set(key, count, timeout=window)
|
||||
if count == maxmisses:
|
||||
logger.warning('usb kiosk: %s unrecognised badges from %s within %ss; '
|
||||
'refusing badge lookups for the rest of the window',
|
||||
count, _kiosk_client_ip(), window)
|
||||
|
||||
|
||||
def _badge_lookups_refused():
|
||||
return error_response(
|
||||
'RATE_LIMITED',
|
||||
'Too many badges this screen does not recognise. Wait a few minutes '
|
||||
'and try again, or see IT.', http_code=429)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provisioning a screen without putting the token in a URL
|
||||
# ---------------------------------------------------------------------------
|
||||
# A query string is the one place a credential is guaranteed to be copied:
|
||||
# browser history, the Referer on the next request, the IIS access log, whatever
|
||||
# the proxy keeps. So the link an administrator carries to a screen holds a code
|
||||
# that is good once and briefly, and the token itself comes back in a Set-Cookie
|
||||
# that no log line carries.
|
||||
#
|
||||
# Only the code's DIGEST is stored, so the pending row is not itself a
|
||||
# credential; the expiry rides with it.
|
||||
|
||||
KIOSK_SETUP_CODE = 'usb_kiosk_setup_code'
|
||||
# Long enough to walk from a desk to the cabinet, short enough that a link left
|
||||
# in a history or a log is already dead when somebody reads it back.
|
||||
KIOSK_SETUP_TTL_SECONDS = 30 * 60
|
||||
# The screen is provisioned once and must not quietly fall out of the kiosk
|
||||
# months later, which is the failure this cookie has to avoid above all.
|
||||
KIOSK_COOKIE_MAX_AGE = 5 * 365 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _issue_kiosk_setup_code():
|
||||
"""Mint a single-use code, store its digest and expiry, return the code."""
|
||||
code = secrets.token_urlsafe(24)
|
||||
digest = hashlib.sha256(code.encode()).hexdigest()
|
||||
expiry = int(time.time()) + KIOSK_SETUP_TTL_SECONDS
|
||||
Setting.set(KIOSK_SETUP_CODE, '{0}|{1}'.format(digest, expiry),
|
||||
valuetype='string', category='usb',
|
||||
description='Digest and expiry of the pending kiosk setup '
|
||||
'code. Written and cleared by the setup link; not '
|
||||
'edited by hand.')
|
||||
return code
|
||||
|
||||
|
||||
def _redeem_kiosk_setup_code(code):
|
||||
"""True exactly once per issued code, and never once it has expired.
|
||||
|
||||
Cleared on ANY attempt. A code is worth one screen; burning it on a wrong
|
||||
guess costs an administrator one click and leaves a prober nothing to
|
||||
retry against.
|
||||
"""
|
||||
stored = (Setting.get(KIOSK_SETUP_CODE) or '').strip()
|
||||
if not stored or '|' not in stored:
|
||||
return False
|
||||
Setting.set(KIOSK_SETUP_CODE, '', valuetype='string', category='usb')
|
||||
digest, _, expiry = stored.partition('|')
|
||||
try:
|
||||
if int(expiry) < int(time.time()):
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
return hmac.compare_digest(
|
||||
digest, hashlib.sha256((code or '').encode()).hexdigest())
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
||||
@require_permission('settings.edit')
|
||||
def kiosk_provisioning_link():
|
||||
@@ -830,9 +1216,16 @@ def kiosk_provisioning_link():
|
||||
|
||||
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
|
||||
this named, permission-gated route hands over a way in to somebody who is
|
||||
already allowed to set it.
|
||||
|
||||
WHAT IT HANDS OVER IS NOT THE TOKEN. The link used to be
|
||||
'/usb-kiosk?token=<value>', which put the credential in a query string: the
|
||||
address bar, the browser history, a Referer, the IIS log and anything in
|
||||
between. It is now a single-use code that kiosk_setup trades for the token
|
||||
over a Set-Cookie. One click here, one open on the screen - the operator
|
||||
flow is the same.
|
||||
|
||||
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.
|
||||
@@ -841,12 +1234,60 @@ def kiosk_provisioning_link():
|
||||
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.
|
||||
code = _issue_kiosk_setup_code()
|
||||
# Issuing a way to claim the token is worth a line in the audit log: the
|
||||
# trail should say who asked, and when the clock on it started.
|
||||
audit_log('viewed', 'Setting', entityname='usb_kiosk_token',
|
||||
details='kiosk provisioning link')
|
||||
details='kiosk setup link issued')
|
||||
db.session.commit()
|
||||
return success_response({'url': '/usb-kiosk?token=' + token})
|
||||
return success_response({
|
||||
'url': '/api/usb/kiosk/setup?code=' + code,
|
||||
'expiresinminutes': KIOSK_SETUP_TTL_SECONDS // 60,
|
||||
})
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/setup', methods=['GET'])
|
||||
def kiosk_setup():
|
||||
"""Trade a setup code for this browser's kiosk cookie, then open the kiosk.
|
||||
|
||||
Opened once on the screen being provisioned. The code is spent here, the
|
||||
token travels back in a Set-Cookie rather than a URL, and the browser is
|
||||
sent on to the kiosk page with nothing left in the address bar.
|
||||
|
||||
HttpOnly, so the page's own JavaScript cannot read the token back out and
|
||||
put it somewhere that does get logged. SameSite=Lax, so another site cannot
|
||||
make this browser POST at the kiosk routes with it. Secure only over https:
|
||||
setting it unconditionally would drop the cookie on the plain-http sites
|
||||
this is deployed on and provision nothing.
|
||||
|
||||
Not gated on usb_kiosk_enabled: a site sets its screens up before opening
|
||||
the door, and a kiosk that is off refuses every call anyway.
|
||||
"""
|
||||
token = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
if not token:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'No kiosk token set. Generate one first.')
|
||||
if not _redeem_kiosk_setup_code(request.args.get('code')):
|
||||
# 410, not 404: this one is read by an operator standing at a screen,
|
||||
# and "gone" plus what to do next is the difference between a working
|
||||
# kiosk and a support call.
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'This setup link has already been used or has expired. Generate a '
|
||||
'new one under Settings > USB.', http_code=410)
|
||||
audit_log('viewed', 'Setting', entityname='usb_kiosk_token',
|
||||
details='kiosk setup link redeemed')
|
||||
db.session.commit()
|
||||
|
||||
mount = request.script_root or ''
|
||||
response = redirect(mount + '/usb-kiosk')
|
||||
response.set_cookie(KIOSK_TOKEN_COOKIE, token,
|
||||
max_age=KIOSK_COOKIE_MAX_AGE, httponly=True,
|
||||
samesite='Lax', secure=request.is_secure,
|
||||
path=mount + '/')
|
||||
# This response carries the token. Nothing anywhere may keep a copy.
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
|
||||
@@ -920,6 +1361,8 @@ def kiosk_session():
|
||||
"""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_session(data)
|
||||
@@ -933,6 +1376,7 @@ def kiosk_session():
|
||||
with conn.cursor() as cur:
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
record_badge_miss()
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT',
|
||||
@@ -978,6 +1422,8 @@ def kiosk_others():
|
||||
return _kiosk_disabled_response()
|
||||
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_others(data)
|
||||
@@ -990,6 +1436,7 @@ def kiosk_others():
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if not _resolve_badge_name(cur, badge):
|
||||
record_badge_miss()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT',
|
||||
http_code=422)
|
||||
@@ -1020,6 +1467,8 @@ def kiosk_checkout():
|
||||
"""Check a device out at the kiosk. Body: {device_id, badge}."""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_checkout(data)
|
||||
@@ -1054,6 +1503,7 @@ def kiosk_checkout():
|
||||
# kiosk there is no signed-in operator to vouch for it.
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
record_badge_miss()
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to check this device out',
|
||||
@@ -1107,6 +1557,8 @@ def kiosk_checkin():
|
||||
"""
|
||||
if not _kiosk_open():
|
||||
return _kiosk_disabled_response()
|
||||
if badge_lookups_exhausted():
|
||||
return _badge_lookups_refused()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_checkin(data)
|
||||
@@ -1131,6 +1583,7 @@ def kiosk_checkin():
|
||||
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
record_badge_miss()
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to return this device',
|
||||
|
||||
@@ -343,6 +343,27 @@ def kiosk_device(device_id):
|
||||
})
|
||||
|
||||
|
||||
def _badge_not_recognised(suffix=''):
|
||||
"""One unrecognised scan, counted against the caller, then the refusal.
|
||||
|
||||
WHY COUNTED. The answer to a recognised badge is an employee's NAME, and a
|
||||
badge is a short number, so this is a lookup somebody admitted to the kiosk
|
||||
can walk. Network-position admission can now mean a whole VLAN rather than
|
||||
one screen by the cabinet, so the number of badges a caller may guess wrong
|
||||
has to be bounded. The budget lives in routes.py alongside the admission
|
||||
checks; the import is deferred because routes imports this module.
|
||||
|
||||
The wording stays per-action ("to check this device out"), because the
|
||||
person at the screen is being told what did not happen.
|
||||
"""
|
||||
from .routes import record_badge_miss
|
||||
|
||||
record_badge_miss()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT' + suffix,
|
||||
http_code=422)
|
||||
|
||||
|
||||
def kiosk_session(data):
|
||||
"""See routes.kiosk_session for why the list costs a valid badge."""
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
@@ -350,8 +371,7 @@ def kiosk_session(data):
|
||||
return badgeerror
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
return _badge_not_recognised()
|
||||
|
||||
yours, available, others = [], [], 0
|
||||
for device in USBDevice.query.filter_by(isactive=True).order_by(
|
||||
@@ -377,8 +397,7 @@ def kiosk_others(data):
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
if not _resolve_name(badge):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
return _badge_not_recognised()
|
||||
|
||||
out = []
|
||||
for device in USBDevice.query.filter_by(isactive=True, ischeckedout=True
|
||||
@@ -419,10 +438,7 @@ def kiosk_checkout(data):
|
||||
# path. A checkout nobody can trace to a person defeats the tracking.
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to check this device out',
|
||||
http_code=422)
|
||||
return _badge_not_recognised(' to check this device out')
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
# The transition IS the guard. The ischeckedout test above reads, decides
|
||||
@@ -477,10 +493,7 @@ def kiosk_checkin(data):
|
||||
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to return this device',
|
||||
http_code=422)
|
||||
return _badge_not_recognised(' to return this device')
|
||||
|
||||
holderbadge = (device.currentuserid or '').strip()
|
||||
if holderbadge and holderbadge != badge:
|
||||
|
||||
@@ -49,9 +49,11 @@
|
||||
<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
|
||||
keeps it; the address bar does not. Turning the kiosk on is a
|
||||
Provision each screen once with <strong>Copy setup link</strong>
|
||||
above and open that link on the screen. The link carries a single-use
|
||||
setup code, not the token, so the token never reaches an address bar,
|
||||
a browser history or a proxy log. That browser keeps the token
|
||||
afterwards. Turning the kiosk on is a
|
||||
decision, not a credential - without this token the routes answer 404,
|
||||
so nobody who merely finds the URL can move a drive and sign it with a
|
||||
badge they guessed.
|
||||
@@ -61,6 +63,18 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Reverse proxies in front of this server</label>
|
||||
<NetworkAllowlistPicker v-model="values.usb_kiosk_trusted_proxies" />
|
||||
<p class="field-hint">
|
||||
Only fill this in when a proxy sits between the kiosk screens and this
|
||||
server. A request arriving FROM one of these addresses is never
|
||||
treated as a kiosk itself, so a proxy that sits inside an allowed
|
||||
network below cannot admit itself. Leave empty when browsers reach
|
||||
the server directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Networks allowed without the token</label>
|
||||
<NetworkAllowlistPicker v-model="values.usb_kiosk_allowed_cidrs" />
|
||||
@@ -78,6 +92,34 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="switch-label">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_wide_cidrs" />
|
||||
Allow an unusually wide allowed network
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Off, a range wider than a site normally uses is refused when you save,
|
||||
and 0.0.0.0/0 is refused outright. Turn this on only to enter a wide
|
||||
range deliberately: every address in it can move drives with no token
|
||||
and no login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="switch-label">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_badge_limit_enabled" />
|
||||
Limit repeated unknown badge lookups
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
The kiosk turns a badge number into an employee name without a login,
|
||||
so an allowed network can otherwise be walked to enumerate staff. On,
|
||||
a source that misses too many times in a row is refused for a while.
|
||||
</p>
|
||||
<label>Unknown badges allowed before refusing</label>
|
||||
<input v-model="values.usb_kiosk_badge_miss_max" type="text"
|
||||
class="form-control" placeholder="15" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
||||
@@ -108,18 +150,25 @@ import { withBase } from '@/utils/basePath'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
|
||||
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
|
||||
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
||||
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs',
|
||||
'usb_kiosk_trusted_proxies', 'usb_kiosk_allow_wide_cidrs',
|
||||
'usb_kiosk_badge_limit_enabled', 'usb_kiosk_badge_miss_max']
|
||||
|
||||
// Keys whose value is text. Everything else is a switch stored as true/false.
|
||||
// Saving a text setting through the boolean path is what replaced the kiosk
|
||||
// token with the word 'true' and 404'd every provisioned screen.
|
||||
const TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
||||
const TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs',
|
||||
'usb_kiosk_trusted_proxies', 'usb_kiosk_badge_miss_max']
|
||||
|
||||
const values = ref({
|
||||
usb_kiosk_enabled: false,
|
||||
usb_kiosk_allowed_cidrs: '',
|
||||
usb_kiosk_allow_return_for_others: true,
|
||||
usb_kiosk_token: '',
|
||||
usb_kiosk_trusted_proxies: '',
|
||||
usb_kiosk_allow_wide_cidrs: false,
|
||||
usb_kiosk_badge_limit_enabled: true,
|
||||
usb_kiosk_badge_miss_max: '15',
|
||||
})
|
||||
|
||||
const tokenJustGenerated = ref(false)
|
||||
|
||||
@@ -121,6 +121,18 @@ class USBPlugin(BasePlugin):
|
||||
|
||||
The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns
|
||||
it on deliberately. Opening that door is a decision about the door.
|
||||
|
||||
DECLARING A KEY HERE IS HALF THE JOB. A site on an air-gapped server
|
||||
changes a setting through the USB settings page or not at all, and that
|
||||
page renders a hardcoded list - frontend/views/USBSettings.vue, its KEYS
|
||||
and TEXTKEYS arrays plus a field for each. A key declared here and absent
|
||||
there is a constant with extra steps.
|
||||
tests/test_plugin_settings_reachable.py is the gate for it, and it is
|
||||
currently RED for four of the keys below: usb_kiosk_trusted_proxies,
|
||||
usb_kiosk_allow_wide_cidrs, usb_kiosk_badge_limit_enabled and
|
||||
usb_kiosk_badge_miss_max. All four need a field on that page. The last
|
||||
two are a checkbox and a number; the first two belong beside the existing
|
||||
allowlist picker, since all three describe the same decision.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
@@ -145,10 +157,13 @@ class USBPlugin(BasePlugin):
|
||||
# Masking keys on the NAME containing 'token'.
|
||||
'valuetype': 'string',
|
||||
'category': 'usb',
|
||||
'description': 'Device token the USB kiosk sends as '
|
||||
'X-Kiosk-Token. Generate one, then open the '
|
||||
'kiosk once as /usb-kiosk?token=<value> on each '
|
||||
'screen',
|
||||
'description': 'Device token the USB kiosk sends on every '
|
||||
'call. Generate one, then use "Copy setup link" '
|
||||
'and open that link once on each screen: the '
|
||||
'link carries a single-use code, not the token, '
|
||||
'so the token never sits in a URL or a proxy '
|
||||
'log. Screens provisioned by an older '
|
||||
'/usb-kiosk?token=<value> link keep working',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allowed_cidrs',
|
||||
@@ -161,10 +176,82 @@ class USBPlugin(BasePlugin):
|
||||
'the kiosk on their own PC and never log in - '
|
||||
'provisioning a token into every browser is not '
|
||||
'workable. Empty = token only. Wall screens keep '
|
||||
'using the token either way. Requires the IIS '
|
||||
'X-Forwarded-For rewrite rule to be live, or the '
|
||||
'app sees the proxy address and this matches '
|
||||
'nothing.',
|
||||
'using the token either way. A range wider than '
|
||||
'/16 is refused unless usb_kiosk_allow_wide_cidrs '
|
||||
'is on, and 0.0.0.0/0 always is. This is only an '
|
||||
'admission check if the caller cannot choose the '
|
||||
'address the app sees: on Windows that means the '
|
||||
'IIS rewrite rule is live (-ClientIpSource '
|
||||
'direct), or a reverse proxy in front appends the '
|
||||
'caller to X-Forwarded-For. The installer checks '
|
||||
'both and says so. Until one holds, leave this '
|
||||
'empty and provision each screen with the token.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_trusted_proxies',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'usb',
|
||||
'description': 'Addresses or CIDRs of the reverse proxies and '
|
||||
'load balancers that sit in front of this '
|
||||
'server, comma separated. These are SUBTRACTED '
|
||||
'from the kiosk allowlist: a request that arrives '
|
||||
'wearing one of these addresses is the proxy '
|
||||
'talking about itself - it forwarded nothing, or '
|
||||
'nothing forwarded at all - so it says nothing '
|
||||
'about who the caller is and is refused rather '
|
||||
'than admitted. Set this whenever a proxy is in '
|
||||
'front, because a proxy usually sits on the very '
|
||||
'plant network the allowlist names, and would '
|
||||
'otherwise match it. The app never reads '
|
||||
'X-Forwarded-For itself; the layer in front of '
|
||||
'it already resolved the caller address.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allow_wide_cidrs',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'usb',
|
||||
'description': 'Allow a kiosk network range wider than /16 '
|
||||
'(/48 for IPv6) to be saved. Off by default '
|
||||
'because the usual reason a wide range appears '
|
||||
'is a typo, and everyone inside one is admitted '
|
||||
'to an unauthenticated write with no token and '
|
||||
'no login. Turn it on only for a site that '
|
||||
'really is one flat network that wide. '
|
||||
'0.0.0.0/0 and ::/0 stay refused either way.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_badge_limit_enabled',
|
||||
'value': 'true',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'usb',
|
||||
'description': 'Stop answering badge lookups for a caller that '
|
||||
'has scanned too many badges this site does not '
|
||||
'recognise. On by default: every kiosk call '
|
||||
'answers with the employee NAME, so an unbounded '
|
||||
'lookup is a directory anyone admitted can walk, '
|
||||
'and admission by network can mean a whole VLAN '
|
||||
'rather than one screen by the cabinet. An '
|
||||
'ordinary day at the cabinet produces no misses '
|
||||
'at all. Turn it off only for a site whose '
|
||||
'scanners misread often enough that people are '
|
||||
'being locked out mid-shift.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_badge_miss_max',
|
||||
'value': '15',
|
||||
'valuetype': 'integer',
|
||||
'category': 'usb',
|
||||
'description': 'How many unrecognised badges one caller may '
|
||||
'scan within ten minutes before the kiosk stops '
|
||||
'answering for the rest of that window. Only '
|
||||
'misses count, so this can sit well above a bad '
|
||||
'scanning day and still stop somebody walking '
|
||||
'the badge range with a loop. Blank, zero or '
|
||||
'anything that is not a number falls back to 15 '
|
||||
'- zero here would refuse the first scan of '
|
||||
'every window, not lift the limit.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allow_return_for_others',
|
||||
@@ -201,6 +288,25 @@ class USBPlugin(BasePlugin):
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
# The kiosk CIDR settings decide who may write to this site without
|
||||
# logging in, and the core settings API stores what it is given: it
|
||||
# checks valuetype and has no per-key hook a plugin can declare. These
|
||||
# are this plugin's keys, so this plugin validates them, at the save,
|
||||
# where the administrator who typed the range is still there to read
|
||||
# why it was refused.
|
||||
#
|
||||
# Guarded because init_app also runs when an operator enables this
|
||||
# plugin on a RUNNING server, and Flask refuses a setup method after
|
||||
# the first request. Symmetric with the blueprint, which the plugin
|
||||
# manager already declines to register at that point: the guard starts
|
||||
# working at the next restart, and a plugin being switched on is not a
|
||||
# moment to fail the whole load over.
|
||||
from .api.routes import validate_kiosk_settings_write
|
||||
try:
|
||||
app.before_request(validate_kiosk_settings_write)
|
||||
except Exception:
|
||||
logger.warning('USB kiosk CIDR validation is not active until this '
|
||||
'app restarts (plugin enabled after first request)')
|
||||
logger.info(f"USB plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
|
||||
@@ -86,6 +86,13 @@ PUBLIC_ENDPOINTS = {
|
||||
|
||||
# USB kiosk.
|
||||
'usb.kiosk_device',
|
||||
# The screen's one-time provisioning hop. It carries no JWT by design: it is
|
||||
# opened on a wall screen that has never signed in, and it exists precisely
|
||||
# so the kiosk token stops travelling in a URL. What it accepts is a
|
||||
# single-use setup code that is redeemed and burned on first use, so it is
|
||||
# open to anybody the way a password-reset link is open to anybody - the
|
||||
# secret is the code, not the route.
|
||||
'usb.kiosk_setup',
|
||||
|
||||
# Client scripts that run as SYSTEM before any user session exists.
|
||||
'printers_asset.printers_for_host',
|
||||
|
||||
@@ -176,16 +176,24 @@ def test_the_probe_records_nothing(usbkiosk, client):
|
||||
# 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.
|
||||
# hold the way out: a named, permission-gated route that hands a working setup
|
||||
# link to somebody already allowed to set the token.
|
||||
|
||||
def test_provisioning_link_carries_the_real_token(usbkiosk, client, auth_headers):
|
||||
def test_provisioning_link_does_not_hand_back_the_mask(usbkiosk, client,
|
||||
auth_headers):
|
||||
"""It used to answer '/usb-kiosk?token=<value>' and this test pinned that
|
||||
literal string. The link has since become a single-use setup code, which is
|
||||
a better answer to the same problem - a query string ends up in history,
|
||||
referrers and proxy logs - so what is held here is the property rather than
|
||||
the shape: a usable link, and never the mask."""
|
||||
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
|
||||
# The token itself must not ride in the URL either.
|
||||
assert 'a-real-token' not in url
|
||||
assert client.get(url).status_code == 302
|
||||
|
||||
|
||||
def test_provisioning_link_needs_permission(usbkiosk, client):
|
||||
|
||||
92
tests/test_plugins/test_printedparts_kiosk_admission.py
Normal file
92
tests/test_plugins/test_printedparts_kiosk_admission.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""How the parts kiosk decides a caller is admitted by network position.
|
||||
|
||||
The USB kiosk closed this on 2026-09-18 and this one did not, so the two read
|
||||
differently for no reason anybody chose. The hole is the same one: a reverse
|
||||
proxy normally sits ON the network a site enters in the allowlist, so a request
|
||||
wearing the proxy's own address matched the range and was admitted while
|
||||
carrying no evidence at all about who sent it.
|
||||
|
||||
Shape of the requests here matches production: the layer in front (IIS with the
|
||||
X-Forwarded-For rewrite, or nginx plus ProxyFix) has already resolved the caller
|
||||
into REMOTE_ADDR, and the app never reads the header itself.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
PLANT = '10.134.48.17'
|
||||
PLANT_CIDR = '10.134.48.0/23'
|
||||
ELSEWHERE = '10.9.100.5'
|
||||
# Inside the allowlisted range, which is where a proxy actually lives and the
|
||||
# reason the un-subtracted check was wrong.
|
||||
PROXY = '10.134.48.5'
|
||||
PROXY_CIDR = '10.134.48.5/32'
|
||||
KIOSK_TOKEN = 'parts-kiosk-admission-token'
|
||||
|
||||
|
||||
def _set(key, value):
|
||||
Setting.set(key, value, valuetype='string', category='printedparts')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kiosk(app, db):
|
||||
_set('printedparts_kiosk_enabled', 'true')
|
||||
_set('printedparts_kiosk_token', KIOSK_TOKEN)
|
||||
_set('printedparts_kiosk_allowed_cidrs', '')
|
||||
_set('printedparts_kiosk_trusted_proxies', '')
|
||||
return app
|
||||
|
||||
|
||||
def _open_from(app, address):
|
||||
"""Ask the gate directly: it is the unit under test, not a route's shape.
|
||||
|
||||
The request context comes from the REAL app, so Setting.get reaches the same
|
||||
database the fixture wrote to.
|
||||
"""
|
||||
from plugins.printedparts.api.routes import _kiosk_open
|
||||
|
||||
with app.test_request_context('/', environ_base={'REMOTE_ADDR': address}):
|
||||
return _kiosk_open()
|
||||
|
||||
|
||||
def test_a_caller_on_an_allowed_network_is_admitted(kiosk):
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _open_from(kiosk, PLANT) is True
|
||||
|
||||
|
||||
def test_a_caller_outside_every_allowed_network_is_refused(kiosk):
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _open_from(kiosk, ELSEWHERE) is False
|
||||
|
||||
|
||||
def test_a_request_wearing_the_proxy_address_fails_closed(kiosk):
|
||||
"""THE FIX. The proxy forwarded nothing, so its own address arrived - and it
|
||||
sits inside the allowlist. No evidence about the sender means refuse."""
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set('printedparts_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _open_from(kiosk, PROXY) is False
|
||||
# ... and the feature still works for a real caller on the same network, or
|
||||
# the subtraction has taken the allowlist with it.
|
||||
assert _open_from(kiosk, PLANT) is True
|
||||
|
||||
|
||||
def test_loopback_is_never_a_kiosk_client(kiosk):
|
||||
"""Needs no configuration: no site's kiosk is the server itself. This is
|
||||
also the shape a misconfigured front end produces, where every request
|
||||
arrives as 127.0.0.1."""
|
||||
_set('printedparts_kiosk_allowed_cidrs', '127.0.0.0/8, ' + PLANT_CIDR)
|
||||
assert _open_from(kiosk, '127.0.0.1') is False
|
||||
|
||||
|
||||
def test_the_kiosk_flag_still_governs(kiosk):
|
||||
"""Network position admits nobody while the kiosk is off."""
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set('printedparts_kiosk_enabled', 'false')
|
||||
assert _open_from(kiosk, PLANT) is False
|
||||
|
||||
|
||||
def test_an_empty_allowlist_admits_nobody(kiosk):
|
||||
"""Token-only, which is the shipped default."""
|
||||
assert _open_from(kiosk, PLANT) is False
|
||||
499
tests/test_plugins/test_usb_kiosk_admission.py
Normal file
499
tests/test_plugins/test_usb_kiosk_admission.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""How the USB kiosk decides a caller is admitted, and what that costs.
|
||||
|
||||
The kiosk writes without a login, so everything here is about the two things
|
||||
standing in for one: where the caller is on the network, and a device token.
|
||||
Three properties this file holds down, each of which was missing:
|
||||
|
||||
- a caller cannot choose its own address. X-Forwarded-For is a request
|
||||
header; unless the site has said where its proxies are, it is ignored.
|
||||
- a range that admits everybody cannot be saved by accident.
|
||||
- the device token does not travel in a URL, and the badge-to-name answer
|
||||
cannot be walked by whoever is admitted.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.api import cache
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
PLANT = '10.134.48.17'
|
||||
PLANT_CIDR = '10.134.48.0/23'
|
||||
ELSEWHERE = '10.9.100.5'
|
||||
# The reverse proxy sits ON the plant network, which is the normal place for it
|
||||
# and the reason the old fail-closed claim was false: its own address matched
|
||||
# the allowlist. A proxy parked outside the range, as this file used to have,
|
||||
# proves nothing.
|
||||
PROXY = '10.134.48.5'
|
||||
PROXY_CIDR = '10.134.48.5/32'
|
||||
KIOSK_TOKEN = 'usb-kiosk-admission-token'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kiosk(db):
|
||||
"""Self-hosted USB, kiosk ON, a token set, one employee, one drive."""
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.usb.models import USBDevice
|
||||
|
||||
# Session-scoped app means one SimpleCache for the whole run, and the badge
|
||||
# limiter counts in it. Start every test with an empty budget.
|
||||
cache.clear()
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
|
||||
valuetype='boolean', category='usb'))
|
||||
db.session.add(Setting(key='usb_kiosk_token', value=KIOSK_TOKEN,
|
||||
valuetype='string', category='usb'))
|
||||
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
|
||||
lastname='Reyes'))
|
||||
db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston',
|
||||
ischeckedout=False))
|
||||
db.session.commit()
|
||||
return db
|
||||
|
||||
|
||||
def _set(db, key, value, valuetype='string'):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
if row is None:
|
||||
db.session.add(Setting(key=key, value=value, valuetype=valuetype,
|
||||
category='usb'))
|
||||
else:
|
||||
row.value = value
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _session(client, address, badge='270015376', **headers):
|
||||
return client.post('/api/usb/kiosk/session', json={'badge': badge},
|
||||
environ_base={'REMOTE_ADDR': address},
|
||||
headers=headers)
|
||||
|
||||
|
||||
# -- where the caller is ------------------------------------------------------
|
||||
# The stack that ships resolves this BEFORE Flask: IIS -> HttpPlatformHandler ->
|
||||
# waitress on loopback with --trusted-proxy=127.0.0.1 --trusted-proxy-count=1,
|
||||
# which takes the last X-Forwarded-For hop into REMOTE_ADDR. So REMOTE_ADDR in
|
||||
# these tests is what that layer decided, and the header beside it is what the
|
||||
# app is required to ignore.
|
||||
|
||||
def test_the_app_admits_on_the_address_the_wsgi_layer_resolved(kiosk, client):
|
||||
"""Production shape: waitress already promoted the caller into REMOTE_ADDR."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_a_caller_cannot_forward_itself_into_the_allowlist(kiosk, client):
|
||||
"""THE FIX. The forwarding header is not evidence of anything here.
|
||||
|
||||
Without it, anyone who could reach the app sent
|
||||
'X-Forwarded-For: <an allowlisted address>' and was admitted to an
|
||||
unauthenticated write from anywhere on the network.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, ELSEWHERE,
|
||||
**{'X-Forwarded-For': PLANT}).status_code == 404
|
||||
|
||||
|
||||
def test_the_header_cannot_take_an_admitted_caller_away_either(kiosk, client):
|
||||
"""Read in either direction, the header is simply not consulted.
|
||||
|
||||
Stated as its own case because a half-measure that only ignored the header
|
||||
when it would have ADMITTED would still leave the app parsing it, and the
|
||||
next person would wire the other branch back up.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT,
|
||||
**{'X-Forwarded-For': ELSEWHERE}).status_code == 200
|
||||
|
||||
|
||||
def test_declaring_a_proxy_does_not_turn_header_reading_back_on(kiosk, client):
|
||||
"""usb_kiosk_trusted_proxies subtracts addresses; it never adds a source.
|
||||
|
||||
The version this replaced read X-Forwarded-For once this setting named the
|
||||
peer. On the Windows stack the peer is never the proxy - waitress replaced
|
||||
it with what the proxy forwarded - so that branch could not fire in
|
||||
production, and anywhere it could it handed the header back its authority.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', '10.20.30.40/32')
|
||||
assert _session(client, '10.20.30.40',
|
||||
**{'X-Forwarded-For': PLANT}).status_code == 404
|
||||
|
||||
|
||||
def test_a_request_wearing_the_proxy_address_fails_closed(kiosk, client):
|
||||
"""THE FIX, and the case the old "fails closed" test did not cover.
|
||||
|
||||
A proxy on the plant network forwards nothing - it is misconfigured, or
|
||||
something bypassed it - so the request arrives as the proxy's own address,
|
||||
which is inside usb_kiosk_allowed_cidrs. It carries no evidence about who
|
||||
sent it and must be refused, not admitted.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY).status_code == 404
|
||||
# And a caller on the same allowlisted network is still admitted, or the
|
||||
# subtraction has taken the feature with it.
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_the_proxy_is_refused_even_carrying_a_forwarding_header(kiosk, client):
|
||||
"""Its own address is what arrived, whatever it says about somebody else."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY,
|
||||
**{'X-Forwarded-For': PLANT}).status_code == 404
|
||||
|
||||
|
||||
def test_loopback_is_never_a_kiosk_client(kiosk, client):
|
||||
"""The shipped Windows default with no X-Forwarded-For at all.
|
||||
|
||||
HttpPlatformHandler connects from 127.0.0.1, waitress has nothing to
|
||||
promote, and every caller then wears the app server's own address. A site
|
||||
whose allowlist happens to cover loopback must not turn that into admission
|
||||
for everyone who can reach the server.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', '127.0.0.0/8, ' + PLANT_CIDR)
|
||||
assert _session(client, '127.0.0.1').status_code == 404
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_a_proxy_address_is_refused_even_when_nobody_declared_it(kiosk, client):
|
||||
"""Honest limit, written down. Loopback is refused with no setting at all;
|
||||
any OTHER proxy address is indistinguishable from a PC on that network, so
|
||||
the site has to name it. This test is what says so out loud."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PROXY).status_code == 200
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY).status_code == 404
|
||||
|
||||
|
||||
def test_the_token_still_works_from_a_proxy_address(kiosk, client):
|
||||
"""Subtracting proxies is about admission BY NETWORK. A screen presenting
|
||||
its device token has said who it is and is not affected."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY,
|
||||
**{'X-Kiosk-Token': KIOSK_TOKEN}).status_code == 200
|
||||
|
||||
|
||||
def test_todays_deployment_is_unchanged(kiosk, client):
|
||||
"""No trusted proxy set - the default - still admits by connecting peer."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
# -- what may be saved in an allowlist ---------------------------------------
|
||||
|
||||
@pytest.mark.parametrize('value, refused', [
|
||||
('', False),
|
||||
('192.0.2.0/24', False),
|
||||
(' 10.134.48.0/23 , 10.48.249.0/26 ', False),
|
||||
('0.0.0.0/0', True),
|
||||
('::/0', True),
|
||||
('10.0.0.0/8', True),
|
||||
('192.0.2.0/24, 0.0.0.0/0', True),
|
||||
('not-a-cidr', True),
|
||||
])
|
||||
def test_kiosk_cidr_problem(value, refused):
|
||||
from plugins.usb.api.routes import kiosk_cidr_problem
|
||||
|
||||
assert (kiosk_cidr_problem(value) is not None) is refused
|
||||
|
||||
|
||||
def test_a_wide_range_is_allowed_when_a_site_says_so_deliberately():
|
||||
from plugins.usb.api.routes import kiosk_cidr_problem
|
||||
|
||||
assert kiosk_cidr_problem('10.0.0.0/8', allowwide=True) is None
|
||||
# Never, though: the override is about a big site, not about no bound.
|
||||
assert kiosk_cidr_problem('0.0.0.0/0', allowwide=True) is not None
|
||||
|
||||
|
||||
def test_saving_everything_is_refused_with_a_reason(kiosk, client, auth_headers):
|
||||
"""THE FIX, at the place the administrator is standing."""
|
||||
response = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '0.0.0.0/0'}, headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'without a login' in message
|
||||
assert Setting.query.filter_by(key='usb_kiosk_allowed_cidrs').first() is None
|
||||
|
||||
|
||||
def test_a_sane_range_still_saves(kiosk, client, auth_headers):
|
||||
response = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': PLANT_CIDR}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert Setting.get('usb_kiosk_allowed_cidrs') == PLANT_CIDR
|
||||
|
||||
|
||||
def test_the_override_lets_a_flat_site_save_its_range(kiosk, client,
|
||||
auth_headers):
|
||||
assert client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '10.0.0.0/8'},
|
||||
headers=auth_headers).status_code == 400
|
||||
_set(kiosk, 'usb_kiosk_allow_wide_cidrs', 'true', valuetype='boolean')
|
||||
assert client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '10.0.0.0/8'},
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
|
||||
def test_an_override_turned_off_stays_off(kiosk, client, auth_headers):
|
||||
"""Stored as a string, 'false' is a truthy Python value. It must not be one."""
|
||||
_set(kiosk, 'usb_kiosk_allow_wide_cidrs', 'false')
|
||||
assert client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '10.0.0.0/8'},
|
||||
headers=auth_headers).status_code == 400
|
||||
|
||||
|
||||
def test_the_trusted_proxy_list_is_validated_too(kiosk, client, auth_headers):
|
||||
"""A proxy list that matches everything would trust every caller's header."""
|
||||
assert client.put('/api/settings/usb_kiosk_trusted_proxies',
|
||||
json={'value': '0.0.0.0/0'},
|
||||
headers=auth_headers).status_code == 400
|
||||
|
||||
|
||||
def test_other_settings_are_not_touched_by_the_guard(kiosk, client,
|
||||
auth_headers):
|
||||
"""The before_request hook runs on every save; it may only judge its own."""
|
||||
response = client.put('/api/settings/site_name',
|
||||
json={'value': '0.0.0.0/0'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# -- who may find out what the rules are --------------------------------------
|
||||
# The validator runs in a before_request, which is ahead of the view and so
|
||||
# ahead of @require_permission. That ordering answered an anonymous caller 400
|
||||
# with the reason a range was refused, while an anonymous caller who happened to
|
||||
# send a sane range fell through to 401 - the difference being a free oracle on
|
||||
# the allowlist rules.
|
||||
|
||||
def test_an_anonymous_probe_is_refused_before_the_value_is_judged(kiosk, client):
|
||||
"""THE FIX. Authentication decides first."""
|
||||
refused = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '0.0.0.0/0'})
|
||||
assert refused.status_code in (401, 403)
|
||||
assert 'without a login' not in refused.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_the_probe_cannot_tell_a_bad_range_from_a_good_one(kiosk, client):
|
||||
"""Both answers must be the same one, or the oracle is still there."""
|
||||
bad = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '0.0.0.0/0'})
|
||||
good = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': PLANT_CIDR})
|
||||
assert bad.status_code == good.status_code
|
||||
assert (bad.get_json()['data']['error']['code']
|
||||
== good.get_json()['data']['error']['code'])
|
||||
assert Setting.query.filter_by(key='usb_kiosk_allowed_cidrs').first() is None
|
||||
|
||||
|
||||
# -- the badge budget is a setting, not a config file -------------------------
|
||||
# It was app-config only, which on an air-gapped server means editing
|
||||
# instance/config.py and restarting IIS. In practice that is no off switch at
|
||||
# all, and the only lever anybody had was turning the whole kiosk off.
|
||||
|
||||
def test_the_badge_limiter_settings_are_declared(kiosk):
|
||||
"""Declared where the app seeds defaults, or nothing creates the rows."""
|
||||
from plugins.usb.plugin import USBPlugin
|
||||
|
||||
declared = {entry['key']: entry
|
||||
for entry in USBPlugin().get_settings_defaults()}
|
||||
assert declared['usb_kiosk_badge_limit_enabled']['value'] == 'true'
|
||||
assert declared['usb_kiosk_badge_limit_enabled']['valuetype'] == 'boolean'
|
||||
assert declared['usb_kiosk_badge_miss_max']['value'] == '15'
|
||||
assert declared['usb_kiosk_badge_miss_max']['valuetype'] == 'integer'
|
||||
|
||||
|
||||
def test_the_budget_setting_moves_the_bound(kiosk, client, app):
|
||||
"""A site dials the number down and the kiosk stops there."""
|
||||
from plugins.usb.api.routes import KIOSK_BADGE_MISS_MAX
|
||||
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, KIOSK_BADGE_MISS_MAX, '2', valuetype='integer')
|
||||
# The app-config override the suite normally uses has to be out of the way,
|
||||
# or it would answer instead of the setting.
|
||||
previous = app.config.pop('USB_KIOSK_BADGE_MISS_MAX', None)
|
||||
app.config['USB_KIOSK_BADGE_LIMIT_ENABLED'] = True
|
||||
try:
|
||||
for _ in range(2):
|
||||
assert _session(client, PLANT, badge='111111111').status_code == 422
|
||||
assert _session(client, PLANT, badge='111111111').status_code == 429
|
||||
finally:
|
||||
app.config.pop('USB_KIOSK_BADGE_LIMIT_ENABLED', None)
|
||||
if previous is not None:
|
||||
app.config['USB_KIOSK_BADGE_MISS_MAX'] = previous
|
||||
|
||||
|
||||
def test_a_nonsense_budget_falls_back_to_the_shipped_one(kiosk, app):
|
||||
"""Zero is not "no limit" here - it would refuse the first scan of every
|
||||
window - and a settings page can store a blank or a typo just as easily."""
|
||||
from plugins.usb.api.routes import (BADGE_MISS_MAX, KIOSK_BADGE_MISS_MAX,
|
||||
_badge_miss_budget)
|
||||
|
||||
previous = app.config.pop('USB_KIOSK_BADGE_MISS_MAX', None)
|
||||
try:
|
||||
for stored in ('', '0', '-4', 'lots'):
|
||||
_set(kiosk, KIOSK_BADGE_MISS_MAX, stored, valuetype='integer')
|
||||
with app.test_request_context('/'):
|
||||
assert _badge_miss_budget()[1] == BADGE_MISS_MAX
|
||||
finally:
|
||||
if previous is not None:
|
||||
app.config['USB_KIOSK_BADGE_MISS_MAX'] = previous
|
||||
|
||||
|
||||
def test_the_kill_switch_is_reachable_without_editing_a_config_file(kiosk, app):
|
||||
"""And it defaults to today's behaviour: on.
|
||||
|
||||
Off under TESTING is an arrangement for this suite, not the default, so the
|
||||
first assertion here is also what stops "the limiter is off" quietly
|
||||
becoming true everywhere.
|
||||
"""
|
||||
from plugins.usb.api.routes import (KIOSK_BADGE_LIMIT_ENABLED,
|
||||
_badge_limit_enabled)
|
||||
|
||||
previous = app.config.pop('USB_KIOSK_BADGE_LIMIT_ENABLED', None)
|
||||
try:
|
||||
with app.test_request_context('/'):
|
||||
app.config['TESTING'] = False
|
||||
try:
|
||||
assert _badge_limit_enabled() is True
|
||||
_set(kiosk, KIOSK_BADGE_LIMIT_ENABLED, 'false',
|
||||
valuetype='boolean')
|
||||
assert _badge_limit_enabled() is False
|
||||
finally:
|
||||
app.config['TESTING'] = True
|
||||
finally:
|
||||
if previous is not None:
|
||||
app.config['USB_KIOSK_BADGE_LIMIT_ENABLED'] = previous
|
||||
|
||||
|
||||
# -- provisioning a screen ----------------------------------------------------
|
||||
|
||||
def test_the_setup_link_does_not_carry_the_token(kiosk, client, auth_headers):
|
||||
"""THE FIX. A query string ends up in history, referrers and proxy logs."""
|
||||
response = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
url = response.get_json()['data']['url']
|
||||
assert KIOSK_TOKEN not in url
|
||||
assert url.startswith('/api/usb/kiosk/setup?code=')
|
||||
|
||||
|
||||
def test_opening_the_setup_link_provisions_that_browser(kiosk, client,
|
||||
auth_headers):
|
||||
"""One open on the screen, and its later calls are admitted with no token."""
|
||||
url = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers).get_json()['data']['url']
|
||||
|
||||
redirected = client.get(url)
|
||||
assert redirected.status_code == 302
|
||||
assert redirected.headers['Location'].endswith('/usb-kiosk')
|
||||
cookie = redirected.headers['Set-Cookie']
|
||||
assert 'usbkiosktoken=' + KIOSK_TOKEN in cookie
|
||||
assert 'HttpOnly' in cookie
|
||||
|
||||
# No X-Kiosk-Token header, no allowlist: the cookie is the whole credential.
|
||||
assert _session(client, ELSEWHERE).status_code == 200
|
||||
|
||||
|
||||
def test_a_setup_code_is_good_once(kiosk, client, auth_headers):
|
||||
url = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers).get_json()['data']['url']
|
||||
assert client.get(url).status_code == 302
|
||||
|
||||
second = client.get(url)
|
||||
assert second.status_code == 410
|
||||
assert 'Generate' in second.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_made_up_setup_code_provisions_nothing(kiosk, client):
|
||||
response = client.get('/api/usb/kiosk/setup?code=not-a-real-code')
|
||||
assert response.status_code == 410
|
||||
assert 'Set-Cookie' not in response.headers
|
||||
|
||||
|
||||
def test_an_expired_setup_code_is_dead(kiosk, client, auth_headers):
|
||||
from plugins.usb.api.routes import KIOSK_SETUP_CODE
|
||||
|
||||
url = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers).get_json()['data']['url']
|
||||
pending = Setting.get(KIOSK_SETUP_CODE)
|
||||
_set(kiosk, KIOSK_SETUP_CODE, pending.split('|')[0] + '|1')
|
||||
assert client.get(url).status_code == 410
|
||||
|
||||
|
||||
def test_the_old_header_still_admits_screens_already_on_the_floor(kiosk,
|
||||
client):
|
||||
"""Screens provisioned before this change must not need a visit."""
|
||||
assert _session(client, ELSEWHERE,
|
||||
**{'X-Kiosk-Token': KIOSK_TOKEN}).status_code == 200
|
||||
|
||||
|
||||
# -- the badge-to-name answer -------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def smallbudget(app):
|
||||
"""Three misses, so the test states the rule rather than counting to 15.
|
||||
|
||||
Also turns the limiter on: it defaults off under TESTING, because the
|
||||
counter lives in a cache that outlives a test and is shared across whatever
|
||||
else runs on the same worker. Same arrangement as the login limiter.
|
||||
"""
|
||||
previous = {key: app.config.get(key)
|
||||
for key in ('USB_KIOSK_BADGE_MISS_MAX',
|
||||
'USB_KIOSK_BADGE_LIMIT_ENABLED')}
|
||||
app.config['USB_KIOSK_BADGE_MISS_MAX'] = 3
|
||||
app.config['USB_KIOSK_BADGE_LIMIT_ENABLED'] = True
|
||||
yield
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = value
|
||||
|
||||
|
||||
def test_badge_guessing_is_bounded_per_source(kiosk, client, smallbudget):
|
||||
"""THE FIX. The kiosk answers with a NAME, so it is a lookup to be walked.
|
||||
|
||||
Admission by network position can mean a whole VLAN, not one screen by the
|
||||
cabinet, which is what makes an unbounded lookup worth somebody's loop.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
for _ in range(3):
|
||||
assert _session(client, PLANT, badge='111111111').status_code == 422
|
||||
refused = _session(client, PLANT, badge='111111111')
|
||||
assert refused.status_code == 429
|
||||
# And a badge that WOULD resolve gets nothing either, or the bound is a
|
||||
# speed bump with a hole in it.
|
||||
assert _session(client, PLANT).status_code == 429
|
||||
|
||||
|
||||
def test_the_bound_is_per_caller_not_per_kiosk(kiosk, client, smallbudget):
|
||||
"""One PC guessing must not shut the cabinet screen down."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
for _ in range(4):
|
||||
_session(client, PLANT, badge='111111111')
|
||||
assert _session(client, PLANT).status_code == 429
|
||||
assert _session(client, '10.134.48.99').status_code == 200
|
||||
|
||||
|
||||
def test_ordinary_use_never_spends_the_budget(kiosk, client, smallbudget):
|
||||
"""Only a badge this site has never heard of counts."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
for _ in range(6):
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_enabling_the_plugin_on_a_running_server_still_works():
|
||||
"""init_app also runs when an operator switches the plugin on live.
|
||||
|
||||
Flask refuses a setup method once the app has served a request, and the
|
||||
loader turns any exception out of init_app into a plugin that failed to
|
||||
load - so an unguarded before_request would have made "enable USB" fail on
|
||||
every running server.
|
||||
"""
|
||||
from flask import Flask
|
||||
|
||||
from plugins.usb.plugin import USBPlugin
|
||||
|
||||
running = Flask(__name__)
|
||||
running.test_client().get('/')
|
||||
USBPlugin().init_app(running, None)
|
||||
Reference in New Issue
Block a user