Merge remote-tracking branch 'origin/wj' into wilm

This commit is contained in:
benjamin.baker
2026-09-18 17:25:24 -04:00
64 changed files with 35788 additions and 20614 deletions

View File

@@ -11,6 +11,7 @@ import datetime
import hashlib
import io
import json
import logging
import zipfile
import pytest
@@ -27,17 +28,51 @@ TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
BASE_URL = 'https://shopdb.test/shopdb'
TEMP_SCRIPT = r'C:\Windows\Temp\shopdb-bootstrap.ps1'
TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
QUERY_SETTING = 'plugin.endpoints.bootstrapquerytoken'
# Admin-only staging, NOT C:\Windows\Temp: a standard user can pre-plant a
# path there and have it run elevated.
STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
STAGED_SCRIPT = STAGE_DIR + r'\shopdb-bootstrap.ps1'
ADMIN_SID = 'S-1-5-32-544'
SYSTEM_SID = 'S-1-5-18'
# The marker the admin "Download .ps1" link carries, so the server can tell an
# admin's browser click apart from a bay still on a pre-change pasted command.
UI_LINK_QUERY = 'via=download'
def _expected_command(secret, base=BASE_URL):
"""Download, run, delete. Never `irm | iex` - Defender kills that shape."""
return ('powershell -ExecutionPolicy Bypass -c "'
"try {{ Invoke-WebRequest -UseBasicParsing "
"'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}' "
"-OutFile '{2}'; & '{2}' }} finally {{ "
"Remove-Item '{2}' -Force -ErrorAction SilentlyContinue }}\""
).format(base, secret, TEMP_SCRIPT)
"""Stage admin-only, download with the token in a header, check the owner,
run, delete. Never `irm | iex` - Defender kills that shape."""
return (
'powershell -ExecutionPolicy Bypass -c "'
"try {{ New-Item -ItemType Directory -Force -Path '{dir}' "
'| Out-Null; '
"icacls '{dir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
"'*{system}:(OI)(CI)F' | Out-Null; "
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
"{{ Write-Host 'Refusing to run: could not restrict "
"{dir} to administrators (icacls failed). Fix the directory "
"or delete it, then run the command again.' -ForegroundColor Red; "
'exit 6 }}; '
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
"Invoke-WebRequest -UseBasicParsing "
"'{base}/api/endpoints/siteagent/bootstrap.ps1' "
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
"if ((Get-Acl '{path}').GetOwner("
'[Security.Principal.SecurityIdentifier]).Value -notin '
"@('{admin}','{system}',"
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
'-ErrorAction SilentlyContinue }}"'
).format(dir=STAGE_DIR, path=STAGED_SCRIPT, base=base, header=TOKEN_HEADER,
secret=secret, admin=ADMIN_SID, system=SYSTEM_SID)
def _token_header(secret):
return {TOKEN_HEADER: secret}
def _make_cert():
@@ -111,8 +146,8 @@ def test_generate_returns_the_secret_and_the_ready_made_command(
assert len(secret) == len('sdbb_') + 43
assert data['command'] == _expected_command(secret)
assert data['scripturl'] == (
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}'.format(BASE_URL,
secret))
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}&{2}'.format(
BASE_URL, secret, UI_LINK_QUERY))
token = data['token']
assert token['tokenprefix'] == secret[:12]
assert token['maxuses'] == 3
@@ -136,13 +171,74 @@ def test_the_command_downloads_and_runs_instead_of_piping_into_iex(
assert 'iex' not in command.lower()
assert '$' not in command
assert '\n' not in command
assert data['scripturl'] in command
assert data['secret'] in command
assert "Remove-Item '{0}' -Force".format(TEMP_SCRIPT) in command
assert "Remove-Item '{0}' -Force".format(STAGED_SCRIPT) in command
assert 'finally' in command
# The link the UI offers instead is the route that really serves it.
assert client.get(SCRIPT_URL,
query_string={'t': data['secret']}).status_code == 200
assert client.get(data['scripturl'].split(BASE_URL, 1)[1]
).status_code == 200
def test_the_command_stages_where_only_administrators_can_write(
client, db, auth_headers):
"""C:\\Windows\\Temp lets any standard user create the exact path the
command runs elevated, so the staging directory moved under Program Files,
is created with an admin-only DACL, and the command refuses to run a staged
file owned by anyone but an administrator."""
command = _secret(client, auth_headers)['command']
assert r'C:\Windows\Temp' not in command
assert "-OutFile '{0}'".format(STAGED_SCRIPT) in command
assert "New-Item -ItemType Directory -Force -Path '{0}'".format(
STAGE_DIR) in command
assert "icacls '{0}' /inheritance:r".format(STAGE_DIR) in command
# Well-known SIDs, so a renamed or localized group cannot widen the grant.
assert "/grant '*{0}:(OI)(CI)F' '*{1}:(OI)(CI)F'".format(
ADMIN_SID, SYSTEM_SID) in command
assert "Get-Acl '{0}'".format(STAGED_SCRIPT) in command
assert 'GetOwner([Security.Principal.SecurityIdentifier]).Value' in command
assert "-notin @('{0}','{1}',".format(ADMIN_SID, SYSTEM_SID) in command
assert '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value' \
in command
assert 'not owned by an administrator' in command
assert 'exit 5' in command
# A leftover from an earlier run is cleared before the download, so the
# owner test cannot pass on stale content.
assert command.index("Remove-Item '{0}'".format(STAGED_SCRIPT)) < \
command.index('Invoke-WebRequest')
def test_the_command_sends_the_token_in_a_header_not_the_query_string(
client, db, auth_headers):
"""A query string lands verbatim in the nginx and gunicorn access logs."""
data = _secret(client, auth_headers)
command = data['command']
assert '?t=' not in command
assert "-Headers @{{'{0}'='{1}'}}".format(TOKEN_HEADER,
data['secret']) in command
assert "'{0}/api/endpoints/siteagent/bootstrap.ps1'".format(
BASE_URL) in command
# The browser download link is the one place that cannot send a header,
# and it is marked so its own fetches are not counted as un-migrated bays.
assert data['scripturl'].endswith(
'?t={0}&{1}'.format(data['secret'], UI_LINK_QUERY))
def test_a_failed_acl_hardening_stops_the_command(client, db, auth_headers):
"""icacls is a native command: it raises nothing and $ErrorActionPreference
never applies to it, so an unchecked call leaves a staging directory that
still inherits the parent DACL and the line downloads into it anyway. The
exit code is read sigil-free, because the whole line must hold no `$`."""
command = _secret(client, auth_headers)['command']
assert '(Get-Variable LASTEXITCODE -ValueOnly) -ne 0' in command
assert 'LASTEXITCODE' in command and '$LASTEXITCODE' not in command
assert 'icacls failed' in command
assert 'exit 6' in command
# Checked before anything is fetched into the directory, not after.
assert command.index('LASTEXITCODE') > command.index('icacls')
assert command.index('LASTEXITCODE') < command.index('Invoke-WebRequest')
def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers):
@@ -226,8 +322,8 @@ def test_script_is_served_for_a_good_token(client, db, auth_headers):
assert response.mimetype == 'text/plain'
script = response.get_data(as_text=True)
script.encode('ascii')
assert '{0}/api/endpoints/siteagent/bootstrap/package?t={1}'.format(
BASE_URL, secret) in script
assert '"{0}/api/endpoints/siteagent/bootstrap/package"'.format(
BASE_URL) in script
assert 'Run as administrator' in script
assert 'GE-Enforce' in script
assert 'SHOPDB_BOOTSTRAP_FORCE' in script
@@ -251,6 +347,163 @@ def test_script_bakes_the_staged_binary_hashes(client, db, auth_headers,
assert hashlib.sha256(traypayload).hexdigest() in script
def test_the_generated_script_fetches_the_package_with_the_header(
client, db, auth_headers):
"""Same reason as the one-liner: the package URL must not carry the token,
or every agent install writes it into an access log."""
secret = _secret(client, auth_headers)['secret']
script = client.get(SCRIPT_URL,
headers=_token_header(secret)).get_data(as_text=True)
assert '?t=' not in script
assert '$BootstrapPackageHeaders = @{{ "{0}" = "{1}" }}'.format(
TOKEN_HEADER, secret) in script
assert ('Invoke-WebRequest -Uri $BootstrapPackageUrl '
'-Headers $BootstrapPackageHeaders') in script
def test_both_routes_take_the_token_in_a_header(client, db, auth_headers,
servercert):
secret = _secret(client, auth_headers, maxuses=2)['secret']
script = client.get(SCRIPT_URL, headers=_token_header(secret))
assert script.status_code == 200
assert 'ShopDB endpoint agent bootstrap' in script.get_data(as_text=True)
assert 'install.ps1' in _files(
client.get(PACKAGE_URL, headers=_token_header(secret)))
assert EndpointBootstrapToken.query.one().usecount == 1
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_a_bad_header_token_is_not_found(client, db, auth_headers, url):
"""The header is read first, so a good ?t= cannot rescue a bad header - one
request carries one token."""
secret = _secret(client, auth_headers)['secret']
response = client.get(url, query_string={'t': secret},
headers=_token_header('sdbb_' + 'a' * 43))
assert response.status_code == 404
def test_the_query_form_still_works_and_is_audited_as_deprecated(
client, db, auth_headers, caplog):
"""A bay running a command pasted before this change must keep working, so
the query form stays accepted; it is flagged in the audit row and warned
about in the log, with no token value in either."""
secret = _secret(client, auth_headers)['secret']
with caplog.at_level(logging.WARNING):
assert client.get(SCRIPT_URL, query_string={'t': secret},
environ_overrides={'REMOTE_ADDR': '10.0.0.6'}
).status_code == 200
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='requested').one()
assert 'query-string token, deprecated' in row.details
assert secret not in row.details
warnings = [record.getMessage() for record in caplog.records
if record.levelno >= logging.WARNING]
assert any('query string' in message and '10.0.0.6' in message
for message in warnings)
assert all(secret not in message for message in warnings)
def test_the_header_form_is_not_flagged_as_deprecated(client, db, auth_headers,
caplog):
secret = _secret(client, auth_headers)['secret']
with caplog.at_level(logging.WARNING):
client.get(SCRIPT_URL, headers=_token_header(secret))
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='requested').one()
assert 'deprecated' not in row.details
assert not [record for record in caplog.records
if record.levelno >= logging.WARNING]
def test_the_admin_download_link_is_not_counted_as_an_un_migrated_bay(
client, db, auth_headers, caplog):
"""The rollout finds un-migrated bays by the deprecation WARNING, and the
UI Download .ps1 link has to use the query form. Marked, it logs at info
and is audited as the admin path, so an admin testing a token does not
invent a bay that is not there."""
data = _secret(client, auth_headers)
path = data['scripturl'].split(BASE_URL, 1)[1]
with caplog.at_level(logging.INFO):
assert client.get(path, environ_overrides={'REMOTE_ADDR': '10.0.0.7'}
).status_code == 200
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='requested').one()
assert 'admin download link' in row.details
assert 'deprecated' not in row.details
assert data['secret'] not in row.details
assert not [record for record in caplog.records
if record.levelno >= logging.WARNING]
infos = [record.getMessage() for record in caplog.records
if record.levelno == logging.INFO]
assert any('Download .ps1' in message and '10.0.0.7' in message
for message in infos)
assert all(data['secret'] not in message for message in infos)
def test_no_log_or_audit_row_ever_carries_the_token_value(
client, db, auth_headers, caplog, servercert):
secret = _secret(client, auth_headers, maxuses=4)['secret']
with caplog.at_level(logging.DEBUG):
client.get(SCRIPT_URL, query_string={'t': secret})
client.get(SCRIPT_URL, headers=_token_header(secret))
client.get(PACKAGE_URL, query_string={'t': secret})
client.get(PACKAGE_URL, headers=_token_header(secret))
assert all(secret not in record.getMessage()
for record in caplog.records)
rows = AuditLog.query.filter_by(
entitytype='EndpointBootstrapToken').all()
assert rows
for row in rows:
assert secret not in (row.details or '')
# The 12-char display prefix is the identifier the UI already shows.
assert row.entityname == secret[:12]
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_the_query_form_can_be_turned_off_once_a_site_has_re_issued(
client, db, auth_headers, url):
"""Off is the hardened end state, not the default: the UI download link
uses the query form too, so a site only turns it off deliberately."""
secret = _secret(client, auth_headers)['secret']
Setting.set(QUERY_SETTING, False, valuetype='boolean')
refused = client.get(url, query_string={'t': secret})
assert refused.status_code == 400
assert TOKEN_HEADER in refused.get_json()['data']['error']['message']
# The admin Download .ps1 link is a query token too: the setting is about
# a live token landing in an access log, and that link puts one there.
assert client.get(url, query_string={'t': secret, 'via': 'download'}
).status_code == 400
# Refused before any lookup, so it confirms nothing about the token.
assert EndpointBootstrapToken.query.one().usecount == 0
assert AuditLog.query.filter_by(
entitytype='EndpointBootstrapToken', action='requested').count() == 0
assert client.get(url, headers=_token_header(secret)).status_code == 200
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_no_token_is_still_not_found_when_the_query_form_is_off(
client, db, auth_headers, url):
"""A bare GET presented no token, so it is unusable and answers 404 like
every other unusable request. Calling it a query token answered 400 naming
the header, which handed an anonymous caller the state of the switch."""
_secret(client, auth_headers)
Setting.set(QUERY_SETTING, False, valuetype='boolean')
assert client.get(url).status_code == 404
# An empty ?t= presented nothing either.
assert client.get(url, query_string={'t': ''}).status_code == 404
# And it is the same answer with the query form still on.
Setting.set(QUERY_SETTING, True, valuetype='boolean')
assert client.get(url).status_code == 404
def test_script_fetch_does_not_spend_a_use(client, db, auth_headers):
secret = _secret(client, auth_headers, maxuses=1)['secret']
for _ in range(3):
@@ -421,3 +674,4 @@ def test_the_command_falls_back_to_the_request_when_no_site_url_is_set(
base='https://localhost')
assert data['scripturl'].startswith(
'https://localhost/api/endpoints/siteagent/bootstrap.ps1?t=')
assert data['scripturl'].endswith('&' + UI_LINK_QUERY)

View File

@@ -689,6 +689,7 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
body = response.get_json()
data = body['data']
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
'revokeddeniessharedkey': False,
'maxpendingenrollments': 500}
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
'pendingcomputers': 1, 'pending': 1}
@@ -711,6 +712,7 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
assert put.status_code == 200
assert put.get_json()['data'] == {'sharedkeyallowed': False,
'autoenroll': True,
'revokeddeniessharedkey': False,
'maxpendingenrollments': 500}
bad = client.put('/api/endpoints/enrollment/settings',
json={'autoenroll': 'yes'}, headers=auth_headers)

View File

@@ -0,0 +1,352 @@
"""What a denial means at the enrollment door, and how long it lasts.
Three denials meet here, and each one used to be undone by the PC on the other
side of it:
reject An admin refuses a request. The agent then sends a new nonce and
the row was re-armed as a fresh cycle - which, with the shared key
and auto-enroll on, auto-issued the credential the admin had just
refused. A rejection binds to the identity (hostname + serial), so
it survives any nonce; only an admin approving it clears it.
cap The pending queue is bounded, and at the cap a PC nobody has seen
before is refused. Abandoned rows come out first, so a queue that
filled with junk drains itself instead of blocking every new PC.
revoke A revoked credential falls back to the shared key, which is how
the un-migrated fleet and the rollback to an older agent keep
reporting. plugin.endpoints.revokeddeniessharedkey (off by
default) turns a revoke into an eviction instead.
"""
from datetime import timedelta
import pytest
from shopdb.core.models import Asset, AssetType, Setting
from plugins.endpoints.api import agentauth
from plugins.endpoints.api.enrollment import (
PENDING_CAP_SETTING, PENDING_STALE_AFTER,
)
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
COLLECTOR_KEY = 'testcollectorkey'
ENROLL_URL = '/api/endpoints/enroll'
ALERTS_URL = '/api/endpoints/alerts'
NONCE = 'n' * 40
OTHER_NONCE = 'm' * 40
@pytest.fixture
def keyheaders(app):
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
@pytest.fixture(autouse=True)
def nolimiter(app):
"""The per-IP limiter counts in a process-wide cache that outlives a test.
Nothing here is about how FAST a PC asks, and another test in the same
worker may already have spent the budget for this window and address, so the
limiter is off for this module and put back as it was.
"""
key = 'ENDPOINTS_ENROLL_RATELIMIT_ENABLED'
previous = app.config.get(key)
app.config[key] = False
yield
if previous is None:
app.config.pop(key, None)
else:
app.config[key] = previous
def _computer(db, hostname, serialnumber='SER-1'):
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype='computer').first()
if atype is None:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
serialnumber=serialnumber, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE):
return {'hostname': hostname, 'serialnumber': serialnumber,
'enrollnonce': nonce, 'agentversion': '1.20.0'}
def _enroll(client, headers, **kwargs):
return client.post(ENROLL_URL, json=_body(**kwargs), headers=headers)
def _pending_request(client, db, keyheaders, **kwargs):
"""A request waiting for an admin, with auto-enroll back on afterwards.
Auto-enroll off is what puts a clean match in the queue instead of issuing
at once; turning it back on is what made the old code hand the retry a
credential, so every rejection test needs both halves.
"""
_setting(db, 'plugin.endpoints.autoenroll', False)
response = _enroll(client, keyheaders, **kwargs)
assert response.status_code == 202, response.get_data(as_text=True)
_setting(db, 'plugin.endpoints.autoenroll', True)
return response.get_json()['data']['requestid']
def _reject(client, auth_headers, requestid):
return client.post(
f'/api/endpoints/enrollment/requests/{requestid}/reject',
headers=auth_headers)
def _setting(db, key, value, valuetype='boolean'):
Setting.set(key, value, valuetype=valuetype, category='plugin.endpoints')
db.session.commit()
def _code(response):
return response.get_json()['data']['error']['code']
# -- a rejection binds to the identity ---------------------------------------
def test_a_new_nonce_does_not_lift_a_rejection(client, db, app, keyheaders,
auth_headers):
"""The bug: reinstall the agent and the retry was auto-issued the
credential an admin had just refused."""
_computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
assert _reject(client, auth_headers, requestid).status_code == 200
retry = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert retry.status_code == 202, retry.get_data(as_text=True)
assert retry.get_json()['data']['reason'] == 'rejected'
assert retry.get_json()['data']['requestid'] == requestid
assert EndpointCredential.query.count() == 0
row = db.session.get(EndpointEnrollRequest, requestid)
assert row.status == EndpointEnrollRequest.STATUS_REJECTED
assert row.decidedat is not None
# Still one row for this identity: the retry did not queue a second.
assert EndpointEnrollRequest.query.count() == 1
def test_a_rejection_survives_however_often_the_pc_asks(client, db, app,
keyheaders,
auth_headers):
_computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
for index in range(5):
again = _enroll(client, keyheaders, nonce=('x%d' % index) * 20)
assert again.status_code == 202
assert again.get_json()['data']['reason'] == 'rejected'
assert EndpointCredential.query.count() == 0
assert EndpointEnrollRequest.query.count() == 1
def test_a_rejection_does_not_touch_a_different_pc(client, db, app,
keyheaders, auth_headers):
"""Identity is hostname AND serial, so one PC's rejection is its own."""
_computer(db, 'BAY-01', serialnumber='SER-1')
other = _computer(db, 'BAY-02', serialnumber='SER-2')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
clean = _enroll(client, keyheaders, hostname='BAY-02',
serialnumber='SER-2')
assert clean.status_code == 201, clean.get_data(as_text=True)
assert EndpointCredential.query.filter_by(assetid=other).one() \
.revokedat is None
def test_an_admin_clears_a_rejection_by_approving_it(client, db, app,
keyheaders, auth_headers):
"""The way back is the admin's, and it works for the reinstalled agent that
now carries a different nonce."""
assetid = _computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
_enroll(client, keyheaders, nonce=OTHER_NONCE)
approve = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert approve.status_code == 200, approve.get_data(as_text=True)
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert issued.status_code == 201, issued.get_data(as_text=True)
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
.enrolledvia == 'approved'
def test_an_approval_out_of_a_rejection_still_binds_to_one_installation(
client, db, app, keyheaders, auth_headers):
"""It binds to the installation the PC was last seen with, so a third
machine claiming the same hostname and serial still cannot collect it."""
assetid = _computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
_enroll(client, keyheaders, nonce=OTHER_NONCE)
client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
impostor = _enroll(client, keyheaders, nonce='z' * 40)
assert impostor.status_code == 403
assert _code(impostor) == 'ENROLL_NONCE_MISMATCH'
assert EndpointCredential.query.count() == 0
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert issued.status_code == 201, issued.get_data(as_text=True)
# -- the pending cap ages out abandoned rows ---------------------------------
def _stale(db, hostname, days):
from datetime import datetime, timezone
seen = (datetime.now(timezone.utc).replace(tzinfo=None)
- timedelta(days=days))
row = EndpointEnrollRequest(
hostname=hostname, serialnumber=hostname + '-SER',
enrollnonce=NONCE, status=EndpointEnrollRequest.STATUS_PENDING,
requestedat=seen, lastseenat=seen)
db.session.add(row)
db.session.commit()
return row
def test_the_cap_ages_out_abandoned_rows_before_refusing(client, db, app):
"""A queue full of PCs that stopped asking must not block the one that is."""
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
_stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
_stale(db, 'GONE-02', PENDING_STALE_AFTER.days + 3)
response = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
serialnumber='NEW-SER'))
assert response.status_code == 202, response.get_data(as_text=True)
hostnames = {row.hostname for row in EndpointEnrollRequest.query.all()}
assert hostnames == {'NEW-01'}
def test_the_age_out_spares_rows_still_being_asked_for(client, db, app):
"""Only rows nothing has touched go, and a decided row never goes: the
rejection above has to outlive any queue pressure."""
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
fresh = _stale(db, 'LIVE-01', 0)
stillasking = _stale(db, 'LIVE-02', 0)
decided = _stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
decided.status = EndpointEnrollRequest.STATUS_REJECTED
db.session.commit()
refused = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
serialnumber='NEW-SER'))
assert refused.status_code == 503
assert _code(refused) == 'ENROLL_PENDING_FULL'
for row in (fresh, stillasking, decided):
assert db.session.get(EndpointEnrollRequest, row.requestid) is not None
# -- revocation and the shared key -------------------------------------------
def test_a_revoked_pc_keeps_the_shared_key_by_default(client, db, app,
keyheaders,
auth_headers):
"""Today's behaviour, and the setting default: a revoke is a rotation, not
an eviction, so the PC keeps reporting while it waits to enroll again."""
assetid = _computer(db, 'BAY-01')
assert _enroll(client, keyheaders).status_code == 201
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
headers=auth_headers)
assert client.get(ALERTS_URL + '?hostname=BAY-01',
headers=keyheaders).status_code == 200
def test_revoked_denies_shared_key_evicts_the_pc(client, db, app, keyheaders,
auth_headers):
assetid = _computer(db, 'BAY-01')
assert _enroll(client, keyheaders).status_code == 201
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True, 'reason': 'stolen'},
headers=auth_headers)
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
refused = client.get(ALERTS_URL + '?hostname=BAY-01', headers=keyheaders)
assert refused.status_code == 401
assert _code(refused) == 'SHARED_KEY_REFUSED'
assert 'revoked' in refused.get_json()['data']['error']['message']
def test_a_pc_that_never_enrolled_is_never_evicted(client, db, app,
keyheaders, auth_headers):
"""The un-migrated fleet has no credential row at all, so the setting can be
turned on without silencing it."""
_computer(db, 'BAY-01', serialnumber='SER-1')
_computer(db, 'BAY-02', serialnumber='SER-2')
assetid = _computer(db, 'BAY-03', serialnumber='SER-3')
_enroll(client, keyheaders, hostname='BAY-03', serialnumber='SER-3')
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
headers=auth_headers)
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
assert client.get(ALERTS_URL + '?hostname=BAY-01',
headers=keyheaders).status_code == 200
assert client.get(ALERTS_URL + '?hostname=BAY-02',
headers=keyheaders).status_code == 200
assert client.get(ALERTS_URL + '?hostname=BAY-03',
headers=keyheaders).status_code == 401
def test_the_collector_contract_follows_the_same_rule(client, db, app,
keyheaders,
auth_headers):
"""The generic collector path asks the plugin, not bind_hostname, so it has
to answer the same way."""
from plugins.endpoints.plugin import EndpointsPlugin
assetid = _computer(db, 'BAY-01')
assert _enroll(client, keyheaders).status_code == 201
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
headers=auth_headers)
refused = EndpointsPlugin().agent_shared_key_refused
with app.test_request_context():
assert refused('BAY-01') is False
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
assert refused('BAY-01') is True
assert refused('UNKNOWN') is False
def test_the_eviction_switch_is_editable_and_defaults_to_today(client, db,
auth_headers):
"""A setting nobody can change is a constant with extra steps."""
read = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
assert read['settings']['revokeddeniessharedkey'] is False
put = client.put('/api/endpoints/enrollment/settings',
json={'revokeddeniessharedkey': True},
headers=auth_headers)
assert put.status_code == 200, put.get_data(as_text=True)
assert put.get_json()['data']['revokeddeniessharedkey'] is True
assert Setting.get(agentauth.REVOKED_DENIES_SHARED_KEY_SETTING) is True
bad = client.put('/api/endpoints/enrollment/settings',
json={'revokeddeniessharedkey': 'yes'},
headers=auth_headers)
assert bad.status_code == 400

View File

@@ -0,0 +1,162 @@
"""The two endpoints switches are declared, seeded, and have a control.
Both were readable by their enforcement code before they were declared:
plugin.endpoints.revokeddeniessharedkey agentauth.revoked_denies_shared_key
plugin.endpoints.bootstrapquerytoken bootstrap._query_token_allowed
An undeclared key has no row until somebody writes one, and until it was
declared the settings API upserted it with valuetype 'string' - so
bool(Setting.get(key, True)) read the text 'false' as True and the switch did
nothing. Declaring it fixes both halves: the row is seeded at plugin
install/enable, and a PUT lands as a real boolean.
The UI half is checked by reading the pages. A switch with no control on an
air-gapped box can only be changed with SQL, which is not a setting.
"""
from pathlib import Path
import pytest
from plugins.endpoints.api import agentauth, bootstrap
from plugins.endpoints.plugin import EndpointsPlugin
from shopdb.core.models import Setting
REPO = Path(__file__).resolve().parents[2]
VIEWS = REPO / 'plugins' / 'endpoints' / 'frontend' / 'views'
REVOKED_KEY = agentauth.REVOKED_DENIES_SHARED_KEY_SETTING
QUERY_KEY = bootstrap.QUERY_TOKEN_SETTING
def _declared():
return {entry['key']: entry for entry in
EndpointsPlugin().get_settings_defaults()}
@pytest.mark.parametrize('key,value', [(REVOKED_KEY, 'false'),
(QUERY_KEY, 'true')])
def test_the_switch_is_declared_with_todays_behaviour(key, value):
entry = _declared().get(key)
assert entry is not None, key + ' is read but never declared'
assert entry['valuetype'] == 'boolean'
assert entry['category'] == 'plugin.endpoints'
# The declared default must be what the code does with no row at all, or
# seeding a site silently changes its behaviour.
assert entry['value'] == value
@pytest.mark.parametrize('key', [REVOKED_KEY, QUERY_KEY])
def test_the_description_names_what_turning_it_on_costs(key):
"""An operator on the shop floor decides from this text alone."""
description = _declared()[key]['description']
assert len(description) > 120, description
if key == REVOKED_KEY:
# The revoke + blockreenroll rollback onto an older agent stops working.
assert 'rollback' in description
assert 'enrolls again' in description
else:
assert 're-issued' in description or 're-issue' in description
def test_the_declared_defaults_match_what_the_code_reads_with_no_row(db):
"""No Setting rows at all: the readers fall back to these same values."""
assert Setting.query.filter_by(key=REVOKED_KEY).first() is None
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
assert agentauth.revoked_denies_shared_key() is False
assert bootstrap._query_token_allowed() is True
def test_the_upgrade_pass_seeds_them_on_a_site_that_installed_earlier(app, db):
"""install/enable fire only on a state transition, so a fleet already
running this plugin gets these rows from `flask plugin upgrade-all`, which
calls sync_all_plugin_settings -> _seed_plugin_settings.
This drives _seed_plugin_settings on a manager built for this test rather
than the process-wide plugin_manager singleton, and that is deliberate.
create_app() re-points that singleton, eight test modules build their own
app, and whatever ran last on this xdist worker owns it: the symptoms are
"no such table: settings" (seeding against another app's in-memory SQLite)
and "'NoneType' object has no attribute 'get_all'" (registry left None).
Both land on whichever test follows, so they move with the split - this one
passed locally and failed on CI for exactly that reason.
init_app is not called here either: it re-registers the plugin blueprints,
and twice on one app raises "The name 'backups' is already registered".
Setting _app and _db is all the seeding routine reads.
"""
from shopdb.plugins import PluginManager
from plugins.endpoints.plugin import EndpointsPlugin
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
manager = PluginManager()
manager._app = app
manager._db = db
assert manager._seed_plugin_settings(EndpointsPlugin()) is True
for key, value in ((REVOKED_KEY, False), (QUERY_KEY, True)):
row = Setting.query.filter_by(key=key).one()
assert row.valuetype == 'boolean'
assert row.category == 'plugin.endpoints'
assert row.get_typed_value() is value
def test_a_put_lands_as_a_boolean_and_flips_the_query_token(client, db,
auth_headers):
"""End to end for bootstrapquerytoken through the API the page uses.
The declaration is what makes the upsert boolean; as a string row the read
below would stay True whatever was saved.
"""
saved = client.put('/api/settings/' + QUERY_KEY, json={'value': False},
headers=auth_headers)
assert saved.status_code == 200, saved.get_json()
body = saved.get_json()['data']
assert body['value'] is False
assert body['valuetype'] == 'boolean'
assert body['category'] == 'plugin.endpoints'
assert bootstrap._query_token_allowed() is False
back = client.put('/api/settings/' + QUERY_KEY, json={'value': True},
headers=auth_headers)
assert back.get_json()['data']['value'] is True
assert bootstrap._query_token_allowed() is True
def test_the_settings_api_lists_the_declared_switch(client, db, auth_headers):
"""The agent-key page reads GET /api/settings/<key> to render the box."""
client.put('/api/settings/' + QUERY_KEY, json={'value': True},
headers=auth_headers)
resp = client.get('/api/settings/' + QUERY_KEY, headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()['data']['value'] is True
def test_the_enrollment_page_carries_the_eviction_checkbox():
page = (VIEWS / 'EnrollmentSettings.vue').read_text(encoding='utf-8')
assert 'toggle-revokeddenies' in page, 'no checkbox for the eviction switch'
assert 'onRevokedDeniesToggle' in page
# It sits with the two switches it belongs beside.
assert 'toggle-sharedkey' in page and 'toggle-autoenroll' in page
# The operator has to be told what it breaks before clicking it.
assert 'rollback' in page
assert 'enrolls again' in page
def test_the_agent_key_page_carries_the_query_token_control():
page = (VIEWS / 'AgentKeySettings.vue').read_text(encoding='utf-8')
assert QUERY_KEY in page, 'the page does not touch the setting'
assert 'toggle-querytoken' in page
assert 'onQueryTokenToggle' in page
# Turning it off breaks commands already handed out, so say so.
assert 're-issue' in page

View File

@@ -289,6 +289,62 @@ def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):
assert json.loads(files['appsettings.json'])['serverpins'] == [leafpin]
def test_installer_restricts_the_registry_key_to_system_and_admins(
client, db, auth_headers, servercert):
"""The shared key stays in HKLM until every consumer is on a per-PC
credential, so what matters meanwhile is who can read it. The key inherits a
DACL that grants BUILTIN\\Users read, which is the exposure the per-PC
migration exists to close - the installer must not leave it that way."""
files = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey='true'))
script = files['install.ps1'].decode('ascii')
assert 'function Protect-HklmShopdbKey' in script
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
# SYSTEM and Administrators by SID, so a localised Windows still matches.
assert '"S-1-5-18", "S-1-5-32-544"' in script
# Inheritance broken WITHOUT copying the inherited entries: copying them is
# what silently preserves the Users read.
assert 'SetAccessRuleProtection($true, $false)' in script
assert 'RemoveAccessRule($rule)' in script
def test_the_key_is_written_before_it_is_locked(client, db, auth_headers, servercert):
"""SetAccessControl replaces the DACL rather than merging, so a lock that ran
first would be undone by nothing - but a write that ran after a lock the
installer no longer holds rights for would fail. Order is the guarantee."""
script = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey='true'))['install.ps1'].decode('ascii')
write = script.index('-Name "CollectorKey" -Value $CollectorKey')
lock = script.index('Protect-HklmShopdbKey -SubKey')
assert write < lock
def test_the_registry_lock_failing_does_not_fail_the_install(
client, db, auth_headers, servercert):
"""A bay that cannot take the ACL still needs its agent. It must say plainly
that the key is readable there, rather than dying or staying quiet."""
script = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey='true'))['install.ps1'].decode('ascii')
lock = script.index('Protect-HklmShopdbKey -SubKey')
tail = script[lock:lock + 600]
assert 'catch' in tail
assert 'readable by any logged-on user' in tail
def test_the_keyless_package_still_locks_the_key(client, db, auth_headers, servercert):
"""ServerPins live in the same key and the registry is authoritative for
pinning, so a standard user must not be able to rewrite them either."""
files = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey=False))
script = files['install.ps1'].decode('ascii')
assert b'CollectorKey' not in files['install.ps1']
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
def test_install_script_helper_without_pins_or_key():
script = siteagent._install_script('https://shopdb.test', '', 900,
serverpins=[], includesharedkey=False)

View File

@@ -0,0 +1,212 @@
"""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
# -- what the page probes on load ---------------------------------------------
def test_empty_badge_probe_separates_refused_from_admitted(usbkiosk, client):
"""The kiosk page asks the server on load whether it may be used.
It cannot guess: the enabled flag is public and only says the site HAS a
kiosk, and a caller admitted by network carries no token to look for. So the
page sends a badge-less session request and reads the status.
That only works while the gate runs BEFORE badge handling - refused is 404,
admitted is the badge validation error. If someone reorders those, the page
starts telling admitted screens they are not set up, so hold it here.
"""
refused = client.post('/api/usb/kiosk/session', json={'badge': ''},
environ_base={'REMOTE_ADDR': ELSEWHERE})
assert refused.status_code == 404
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
admitted = client.post('/api/usb/kiosk/session', json={'badge': ''},
environ_base={'REMOTE_ADDR': PLANT})
assert admitted.status_code != 404
assert admitted.status_code < 500
def test_the_probe_records_nothing(usbkiosk, client):
"""A page load must not leave a checkout, a log row, or a resolved badge."""
from plugins.usb.models import USBCheckout
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
client.post('/api/usb/kiosk/session', json={'badge': ''},
environ_base={'REMOTE_ADDR': PLANT})
assert USBCheckout.query.count() == 0
# -- provisioning link --------------------------------------------------------
# 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 a working setup
# link to somebody already allowed to set the token.
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 '*' 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):
"""Anonymous callers cannot read the token out of this route."""
response = client.get('/api/usb/kiosk/provisioning-link')
assert response.status_code in (401, 403)
def test_provisioning_link_says_so_when_no_token_is_set(usbkiosk, client,
auth_headers):
Setting.query.filter_by(key='usb_kiosk_token').first().value = ''
usbkiosk.session.commit()
response = client.get('/api/usb/kiosk/provisioning-link',
headers=auth_headers)
assert response.status_code == 400
assert 'Generate' in response.get_json()['data']['error']['message']

View File

@@ -0,0 +1,77 @@
"""usb and printedparts declare a core_version floor they actually need.
Both reach for the network-position allowlist that arrived on the contract
surface in 0.35.0 (commit "Admit kiosk callers by network position"): ip_in_cidrs
and request_ip_allowlisted landed together in that bump, so either one witnesses
the same floor. Their manifests claimed >=0.1.0 and >=0.16.0, so the loader would
have accepted them on a core whose shopdb.api has no such name - the plugin then
fails at first import of its routes instead of at the version check that exists
to catch exactly this.
The witness is named PER PLUGIN and not shared. usb needs the matcher itself,
because its admission is the allowlist minus the site's proxies and the
addresses no caller can have, which the one-call helper does not express;
printedparts is the plain case and uses ip_in_cidrs. A single shared
name went red the moment usb moved, which said "the floor is wrong" when the
floor was right.
"""
import json
import pathlib
import pytest
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from shopdb import __contract_version__
PLUGINS = pathlib.Path(__file__).resolve().parents[2] / 'plugins'
# The contract version that added request_ip_allowlisted / ip_in_cidrs.
IP_ALLOWLIST_CONTRACT = '0.35.0'
# plugin, the 0.35.0 name it really imports, the floor that name implies
CASES = [
('usb', 'ip_in_cidrs', IP_ALLOWLIST_CONTRACT),
# Was request_ip_allowlisted until 2026-09-18, when the parts kiosk moved to
# client_ip + ip_in_cidrs so it could SUBTRACT proxy and loopback addresses
# before consulting the allowlist. Same contract release, same floor.
('printedparts', 'ip_in_cidrs', IP_ALLOWLIST_CONTRACT),
]
def _manifest(name):
return json.loads((PLUGINS / name / 'manifest.json').read_text())
@pytest.mark.parametrize('name,witness,needed', CASES)
def test_manifest_excludes_a_core_without_the_name(name, witness, needed):
"""A core one minor below the name's arrival must be refused."""
spec = SpecifierSet(_manifest(name)['core_version'])
too_old = Version(needed)
too_old = Version(f'{too_old.major}.{too_old.minor - 1}.0')
assert too_old not in spec
@pytest.mark.parametrize('name,witness,needed', CASES)
def test_manifest_admits_the_running_core(name, witness, needed):
"""And the floor must not have overshot what this core provides."""
spec = SpecifierSet(_manifest(name)['core_version'])
assert Version(__contract_version__) in spec
@pytest.mark.parametrize('name,witness,needed', CASES)
def test_plugin_really_imports_the_name(name, witness, needed):
"""Pins the floor to the reason for it: drop the import and this test says
the floor can come back down."""
sources = (PLUGINS / name).rglob('*.py')
assert any(witness in path.read_text() for path in sources), name
@pytest.mark.parametrize('name,witness,needed', CASES)
def test_the_name_is_on_the_contract_surface(name, witness, needed):
"""Each witness is a contract name, not an internal import a plugin reached
through - which is the only reason the manifest floor can speak for it."""
import shopdb.api
assert witness in shopdb.api.__all__

View File

@@ -0,0 +1,93 @@
"""alertscope is TEXT in the database, a dict in Python.
Migration 0006 ships the column as TEXT because a site still runs MySQL 5.6,
which has no JSON type. The model declared db.JSON, so create-all built a column
the migration path never produces and the two disagreed about the same column.
"""
import json
from sqlalchemy import Text, inspect
from plugins.notifications.models.notification import Notification
def test_column_type_matches_the_migration(app):
"""The model's storage type must be the one migration 0006 creates."""
column = inspect(Notification).columns['alertscope']
assert isinstance(column.type.impl_instance
if hasattr(column.type, 'impl_instance')
else column.type.impl, Text)
def test_ddl_has_no_json_column(app, db):
"""MySQL 5.6 fails on a JSON column with a bare syntax error, so the
emitted DDL must not contain one."""
from sqlalchemy.schema import CreateTable
ddl = str(CreateTable(Notification.__table__).compile(db.engine))
alertscope_line = [line for line in ddl.splitlines()
if 'alertscope' in line][0]
assert 'JSON' not in alertscope_line.upper()
def test_dict_round_trips(app, db):
"""A scope assigned as a dict reads back as a dict."""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0,
alertscope={'hostname': ['BAY-01', 'BAY-02']})
db.session.add(row)
db.session.commit()
notificationid = row.notificationid
db.session.expire_all()
reread = db.session.get(Notification, notificationid)
assert reread.alertscope == {'hostname': ['BAY-01', 'BAY-02']}
def test_stored_value_is_json_text(app, db):
"""What lands in the column is a JSON string, readable by a site that
queries the table directly."""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0, alertscope={'computertype': 'CMM PC'})
db.session.add(row)
db.session.commit()
raw = db.session.execute(
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
{'i': row.notificationid}).scalar()
assert isinstance(raw, str)
assert json.loads(raw) == {'computertype': 'CMM PC'}
def test_null_scope_stays_null(app, db):
"""No scope means the whole fleet; it must not become the string 'null'."""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0, alertscope=None)
db.session.add(row)
db.session.commit()
raw = db.session.execute(
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
{'i': row.notificationid}).scalar()
assert raw is None
assert row.alertscope is None
def test_unparseable_text_is_handed_back_raw(app, db):
"""A hand-edited row must be visible and fixable, not an exception on read.
The endpoints matcher already treats a non-dict scope as matching nothing.
"""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0)
db.session.add(row)
db.session.commit()
db.session.execute(
db.text('UPDATE notifications SET alertscope = :v '
'WHERE notificationid = :i'),
{'v': 'not json at all', 'i': row.notificationid})
db.session.commit()
db.session.expire_all()
reread = db.session.get(Notification, row.notificationid)
assert reread.alertscope == 'not json at all'

View 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

View 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)

View File

@@ -137,3 +137,66 @@ def test_the_report_is_registered_so_it_appears_in_the_catalogue(client, usbdata
assert report['id'] == 'usb-audit'
# The endpoint is what the reports page fetches; a wrong one renders blank.
assert report['endpoint'] == '/api/usb/reports/audit'
# -- sanitized / virus-scan state in the audit ---------------------------------
# CMMC evidence: the report answered who had which drive and when, but not
# whether it was wiped or scanned on the way back - which is the question the
# record exists to answer. Both stores held the values; neither reported them.
def test_audit_reports_wipe_and_scan_state_on_returns(db, client, auth_headers):
from plugins.usb.models import USBDevice, USBCheckout
from shopdb.core.models import Setting
from datetime import datetime, timedelta
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
device = USBDevice(serialnumber='USB-9', label='Grey SanDisk')
db.session.add(device)
db.session.flush()
now = datetime.utcnow()
db.session.add(USBCheckout(
usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana',
checkouttime=now - timedelta(hours=2), checkintime=now - timedelta(hours=1),
waswiped=True, scannedviruses=False))
db.session.commit()
response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()['data']
assert 'sanitized' in data['columns']
assert 'scanned_viruses' in data['columns']
checkin = [r for r in data['rows'] if r['action'] == 'check-in'][0]
checkout = [r for r in data['rows'] if r['action'] == 'check-out'][0]
assert checkin['sanitized'] == 'yes'
assert checkin['scanned_viruses'] == 'no'
# A take carries neither: both are recorded when the drive comes back.
assert checkout['sanitized'] == ''
assert checkout['scanned_viruses'] == ''
def test_unrecorded_wipe_is_blank_not_no(db, client, auth_headers):
"""A null means nobody wrote it down. Rendering that as 'no' would assert
something no one ever claimed, in the one record kept to be trustworthy."""
from plugins.usb.models import USBDevice, USBCheckout
from shopdb.core.models import Setting
from datetime import datetime, timedelta
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
device = USBDevice(serialnumber='USB-10', label='Old stock')
db.session.add(device)
db.session.flush()
now = datetime.utcnow()
db.session.add(USBCheckout(
usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana',
checkouttime=now - timedelta(hours=3), checkintime=now - timedelta(hours=2),
waswiped=None, scannedviruses=None))
db.session.commit()
response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers)
checkin = [r for r in response.get_json()['data']['rows']
if r['action'] == 'check-in'][0]
assert checkin['sanitized'] == ''
assert checkin['scanned_viruses'] == ''