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:
138
tests/test_plugins/test_kiosk_network_allowlist.py
Normal file
138
tests/test_plugins/test_kiosk_network_allowlist.py
Normal 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
|
||||
Reference in New Issue
Block a user