Admit kiosk callers by network position, not only by device token

The device token assumes a handful of fixed screens an administrator
provisions once. That is not how these kiosks are used: most people open the
page on their own PC, and there is no login, so per-browser provisioning would
mean touching every PC on the floor and losing it again on the next reimage or
cleared profile. A screen in that state did not look refused either - the USB
kiosk rendered a working page whose every call answered 404.

Both kiosks now admit a caller on either ground, the same shape the GE-Enforce
client endpoints already use: the per-device token, or a source address inside
an allowlist. Wall screens keep the token and are not confined to the
allowlist; everyone else is admitted by where they are on the network. Still
gated on the site's enable flag, so nothing opens by itself, and both
allowlists ship empty - token-only until a site enters its ranges.

- shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0).
  On the surface because both plugins need the identical gate, and a CIDR
  parser copied into two of them is where a fail-open creeps in later. They
  fail closed on an empty setting, an unparseable caller address and a
  malformed entry, and they read remote_addr rather than raw X-Forwarded-For -
  so the IIS rewrite rule is what makes them meaningful, which the module says
  at length.
- Separate settings per kiosk, usb_kiosk_allowed_cidrs and
  printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same
  reason the tokens are separate.
- Both settings pages get a picker listing the network plugin's subnets with
  their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has
  to work at a site that does not run that plugin and must not break when an
  inventory row is deleted. Entries matching no subnet are shown rather than
  hidden, since a stale one left by an inventory edit looks exactly like a
  hand-typed range.
- USBKiosk says it was not admitted instead of pretending to work, which
  PartsKiosk already did.
- The USB settings page keyed text-vs-boolean off the token alone. Adding a
  second text setting to the boolean path would have saved it as the word
  'true' - the bug that replaced the kiosk token and 404'd every provisioned
  screen. Both load and save now key off a list, with a spec on it.

The trade, stated plainly in the admin UI: anyone on an allowed network can
check a drive in or out against a badge number they guess, and the record
carries that person's name. Badges are attribution, not authorization. Keeping
the ranges narrow is what bounds it; login is the alternative and was ruled out
because these users do not sign in.
This commit is contained in:
cproudlock
2026-09-16 14:33:22 -04:00
parent 158ee0507e
commit da91a29155
13 changed files with 530 additions and 26 deletions

View File

@@ -0,0 +1,138 @@
"""Network-position admission for the two unauthenticated kiosks.
The device token covers unattended screens an administrator provisions once. It
does not cover the other caller: somebody opening the kiosk page on their own
PC, who never logs in and whose browser nobody provisioned. Per-browser
provisioning would mean touching every PC on the floor and losing it on the next
reimage, so those callers are admitted by where they are on the network instead.
What these tests hold down:
- the allowlist actually admits a caller with no token (the feature),
- it stays shut when unset, when the caller is outside it, and when the kiosk
itself is off (the flag still wins),
- a malformed entry is skipped rather than widening the match, and
- the two kiosks do not share an allowlist.
"""
import pytest
from shopdb.core.models import Setting
from shopdb.api import ip_in_cidrs
PLANT = '10.134.48.17'
PLANT_CIDR = '10.134.48.0/23'
ELSEWHERE = '10.9.100.5'
# -- the pure matcher ---------------------------------------------------------
# Separated from the request so the fail-closed edges can be stated plainly.
@pytest.mark.parametrize('address, cidrs, expected', [
(PLANT, PLANT_CIDR, True),
(PLANT, ' 10.48.249.0/26 , 10.134.48.0/23 ', True),
(ELSEWHERE, PLANT_CIDR, False),
# Empty or absent setting means the feature is off, not "allow everyone".
(PLANT, '', False),
(PLANT, None, False),
# A malformed entry must be skipped, never treated as a wildcard.
(PLANT, 'not-a-cidr', False),
(PLANT, 'not-a-cidr, 10.134.48.0/23', True),
# An unparseable caller address matches nothing.
('', PLANT_CIDR, False),
('unknown', PLANT_CIDR, False),
])
def test_ip_in_cidrs(address, cidrs, expected):
assert ip_in_cidrs(address, cidrs) is expected
# -- USB kiosk ----------------------------------------------------------------
@pytest.fixture
def usbkiosk(db):
"""Self-hosted USB, kiosk ON, a token set, one employee and one drive."""
from plugins.employees.models import DirectoryEmployee
from plugins.usb.models import USBDevice
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='a-real-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 _allow(db, key, value):
row = Setting.query.filter_by(key=key).first()
if row is None:
db.session.add(Setting(key=key, value=value, valuetype='string',
category='usb'))
else:
row.value = value
db.session.commit()
def _session(client, address):
"""A kiosk call carrying NO token, from `address`."""
return client.post('/api/usb/kiosk/session', json={'badge': '270015376'},
environ_base={'REMOTE_ADDR': address})
def test_usb_kiosk_refuses_tokenless_caller_when_no_allowlist(usbkiosk, client):
"""The state before this feature: no token, no way in."""
assert _session(client, PLANT).status_code == 404
def test_usb_kiosk_admits_tokenless_caller_on_allowed_network(usbkiosk, client):
"""THE FEATURE. Fails without the allowlist branch in _kiosk_open."""
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
assert _session(client, PLANT).status_code == 200
def test_usb_kiosk_refuses_caller_outside_the_allowlist(usbkiosk, client):
"""The PXE LAN is also 10.x, which is why the range has to be narrow."""
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
assert _session(client, ELSEWHERE).status_code == 404
def test_usb_kiosk_allowlist_does_not_override_the_off_switch(usbkiosk, client):
"""Turning the kiosk off closes it to everyone, allowlisted or not.
The flag is the site's decision to have this door at all; the allowlist only
decides who walks through an open one.
"""
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
Setting.query.filter_by(key='usb_kiosk_enabled').first().value = 'false'
usbkiosk.session.commit()
assert _session(client, PLANT).status_code == 404
def test_usb_kiosk_token_still_works_from_any_network(usbkiosk, client):
"""Wall screens keep working, and are not confined to the allowlist."""
response = client.post('/api/usb/kiosk/session',
json={'badge': '270015376'},
headers={'X-Kiosk-Token': 'a-real-token'},
environ_base={'REMOTE_ADDR': ELSEWHERE})
assert response.status_code == 200
def test_usb_allowlist_does_not_open_the_parts_kiosk(usbkiosk, client):
"""Separate screens, separate rooms, separate settings.
The tokens are deliberately separate so provisioning one does not grant the
other. An allowlist shared between them would hand back exactly that.
"""
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
Setting.query.filter_by(key='usb_kiosk_enabled').first().value = 'true'
usbkiosk.session.add(Setting(key='printedparts_kiosk_enabled', value='true',
valuetype='boolean', category='printedparts'))
usbkiosk.session.commit()
response = client.get('/api/printedparts/kiosk/lookup?code=ANY',
environ_base={'REMOTE_ADDR': PLANT})
assert response.status_code == 404