The bootstrap command staged its script into C:\Windows\Temp and then ran it elevated. That directory grants Users create-file with CREATOR OWNER full control, so somebody without admin rights can pre-plant the exact path the elevated run is about to execute. It now stages under Program Files, creates the directory with an explicit DACL when it is missing, and refuses to run a staged file that is not owned by an admin principal. A failed hardening is loud rather than silent: the run stops instead of carrying on with a file it could not protect. The token moved out of the query string into a header. In a query string it lands verbatim in the reverse proxy and IIS logs, which is a credential written to a file nobody thinks of as a secret store. The query form is still accepted, because bays provisioned before this change are holding install commands that use it, and it logs that it was used - without logging the token - so a site can see who still needs a re-issued command before turning it off with plugin.endpoints.bootstrapquerytoken. Two smaller things this also closes: A request with no token at all answered 400 naming the header, which told an anonymous caller the state of that switch and contradicted the module's own promise that anything unusable answers 404. No token is now 404. The 400 is kept for a token genuinely presented in the query form while the query form is off, which is a caller who needs to know their command is stale. The rollout documentation was republishing the vulnerable one-liner verbatim, so a tech following the runbook reinstated exactly what this fixes. Both documents now carry the current command. Also corrects the operator-facing copy for the token, which named a query argument and a header that do not exist: it is ?t= and X-Endpoint-Bootstrap-Token, per bootstrap.py's own constants. Anybody following the old text got a 404 with no reason given. Both switches added in this range are now declared with the other plugin settings and reachable from their settings page. Read through Setting.get but never declared, they were rows that did not exist until someone wrote one by hand, which on an air-gapped box means the feature was unreachable.
146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""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."""
|
|
pm = app.extensions.get('plugin_manager')
|
|
if pm is None:
|
|
pytest.skip('no plugin manager in this build')
|
|
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
|
|
|
|
pm.sync_all_plugin_settings()
|
|
|
|
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
|