Files
shopdb-flask/plugins/usb/plugin.py
cproudlock 7ac1ee3077 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.
2026-09-18 13:30:24 -04:00

341 lines
17 KiB
Python

"""USB plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .models import USBDevice, USBDeviceType, USBCheckout
from .api import usb_bp
logger = logging.getLogger(__name__)
class USBPlugin(BasePlugin):
"""
USB plugin - manages USB device tracking and checkouts.
Standalone plugin for tracking USB flash drives, external drives,
and other portable storage devices with checkout/checkin functionality.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'usb'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'USB device checkout management'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/usb'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return usb_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [USBDeviceType, USBDevice, USBCheckout]
def get_provisioning_note(self) -> Optional[Dict]:
return {
'mode_setting': 'usb_directory_mode',
'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'],
'note': ('Enabling this creates USB tracking tables in the shopdb '
'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC '
'removable-media check-in/out. Devices, check-in/out events, '
'and per-event log ids live here. Planned build-out: a USB-ID '
'standard, overdue-device email reminders, and DLP/Fabric '
'approval integration (see the captured design notes). A site '
'with an existing cmmc_usb database can use external mode '
'instead.'),
'docs': 'plugins/usb/README.md',
}
def get_cli_commands(self) -> List:
"""CLI: import records off an external cmmc_usb database."""
from .cli import build_cli
return build_cli()
def get_reports(self) -> List[Dict]:
"""Removable-media audit.
CMMC tracking is an audit obligation, and the only way to answer "who
had which drive, when" was to page through the check-in/out list by
hand. The window covers a shift, a periodic review, or everything ever
recorded.
"""
return [
{
'id': 'usb-audit',
'name': 'USB Check-in / Check-out Audit',
'description': 'Every take and return over a chosen window - '
'day, month, six months, year or all time - with '
'who, which drive and when',
'category': 'usage',
'endpoint': '/api/usb/reports/audit',
},
]
def get_settings_cards(self) -> List[Dict]:
"""Somewhere to actually turn the kiosk on.
The kiosk page tells an administrator to enable it in Settings under
USB. Without this card that place does not exist, and the instruction
is a dead end - the same trap the backups plugin shipped with, where
settings existed but only a shell could reach them.
"""
return [
{
'group': 'USB Devices',
'to': '/settings/usb',
'icon': 'usb',
'title': 'USB Settings',
'description': 'Logged-out kiosk for taking and returning '
'drives, and whether somebody may return a drive '
'checked out to another person',
'position': 45,
},
]
def get_settings_defaults(self) -> List[Dict]:
"""Kiosk settings.
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 [
{
'key': 'usb_kiosk_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'usb',
# Public: the kiosk page runs logged out and has to be able to
# tell "turned off" from "broken" before anyone signs in.
'public': True,
'description': 'Allow check-out and check-in from the logged-out '
'USB kiosk screen (/usb-kiosk). Off by default: the '
'kiosk writes without a login, attributing every '
'action to a scanned badge instead. It can only move '
'a device between checked-out and checked-in - it '
'cannot add, edit, retire or list devices.',
},
{
'key': 'usb_kiosk_token',
'value': '',
# string: the column documents string/boolean/integer/json.
# Masking keys on the NAME containing 'token'.
'valuetype': 'string',
'category': 'usb',
'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',
'value': '',
'valuetype': 'string',
'category': 'usb',
'description': 'Networks allowed to use the kiosk WITHOUT the '
'device token, as comma-separated CIDRs (for '
'example 192.0.2.0/24). For people who open '
'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. 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',
'value': 'true',
'valuetype': 'boolean',
'category': 'usb',
'public': True,
'description': 'At the kiosk, allow somebody to return a device '
'checked out to a DIFFERENT person after confirming. '
'The returning badge is what gets recorded, so the '
'log shows who actually handed it back. Turn this '
'off to refuse any mismatch outright - a device left '
'behind by someone on leave then needs a signed-in '
'user to return it.',
},
]
def get_config_schema(self) -> List[Dict]:
"""CMMC USB check-in/out database connection. Host/name/user are settings
the wizard can edit; the password stays in .env (emitted, not stored)."""
return [
{'key': 'cmmc_usb_db_host', 'label': 'USB DB host', 'type': 'text',
'secret': False, 'default': 'localhost',
'help': 'This DB must expose devices / checkinoutlog / users tables '
'(or views). See plugins/usb/README.md.'},
{'key': 'cmmc_usb_db_name', 'label': 'USB DB name', 'type': 'text',
'secret': False, 'default': 'cmmc_usb'},
{'key': 'cmmc_usb_db_user', 'label': 'USB DB user', 'type': 'text',
'secret': False},
{'key': 'cmmc_usb_db_password', 'label': 'USB DB password', 'type': 'password',
'secret': True, 'envvar': 'CMMC_USB_DB_PASSWORD',
'help': 'Stored in .env, not the database. The wizard shows the line to paste.'},
]
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:
"""Called when plugin is installed."""
logger.info("USB plugin installed")
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("USB plugin uninstalled")
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'USB Devices',
'icon': 'usb',
'route': '/usb',
'permission': 'usb.view',
'position': 25,
'section': 'assets',
},
]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('usb.view', 'View USB devices', 'usb'),
('usb.create', 'Create USB devices', 'usb'),
('usb.edit', 'Edit USB devices', 'usb'),
('usb.delete', 'Delete USB devices', 'usb'),
]