Promote the 2026-08-30 review security fixes and supplies ordering to main

This commit is contained in:
2026-08-31 09:24:13 -04:00
parent 6680c3c5de
commit 0e3d5a6226
30 changed files with 991 additions and 98 deletions

View File

@@ -79,3 +79,73 @@ def test_a_package_with_no_recorded_digest_is_refused_not_delivered_unverified(a
assert descriptor is None
assert 'SHA-256' in error
# ---------------------------------------------------------------------------
# The digest is pinned when an operator approves the push, not when the agent
# claims it. Between those two moments the bytes behind a descriptor can be
# replaced by anyone who can edit the catalog, and the re-resolved descriptor
# would faithfully describe the NEW bytes.
# ---------------------------------------------------------------------------
def test_an_unpinned_command_still_claims(app):
"""Actions that deliver no content, and rows queued before the pin existed."""
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='mtconnect_start',
pinnedsha256=None)
assert _pin_holds(command, None) is True
assert _pin_holds(command, {'sha256': 'b' * 64}) is True
def test_a_matching_digest_claims(app):
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='run_script',
pinnedsha256='c' * 64)
assert _pin_holds(command, {'sha256': 'c' * 64}) is True
def test_a_swapped_digest_does_not_claim(app):
"""The finding this exists for: approve one script, ship another.
An operator queues run_script against a reviewed body. Before the agent's
long poll returns, somebody with applications.edit replaces the body and
the digest is restamped. Without the pin the claim re-resolves, hands the
agent the attacker's digest, and the agent verifies it correctly.
"""
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='run_script',
pinnedsha256='c' * 64)
assert _pin_holds(command, {'sha256': 'd' * 64}) is False
def test_a_vanished_descriptor_is_not_treated_as_a_swap(app):
"""A deleted script already sends the command through with no descriptor so
the agent fails closed; that path must not be reported as tampering."""
from plugins.endpoints.api.routes import _pin_holds
from plugins.endpoints.models import EndpointCommand
with app.app_context():
command = EndpointCommand(assetid=1, action='run_script',
pinnedsha256='c' * 64)
assert _pin_holds(command, None) is True
def test_enqueue_records_the_digest_it_approved(app, hosted_app):
"""The resolved descriptor used to be discarded at enqueue (`_descriptor`)."""
from plugins.endpoints.api.routes import _resolve_update_app
appid, appversionid = hosted_app
with app.app_context():
descriptor, error = _resolve_update_app(str(appversionid))
assert error is None
# This is the value the enqueue path now writes to pinnedsha256.
assert descriptor['sha256'] == 'a' * 64

View File

@@ -107,21 +107,47 @@ def test_payload_download_is_rate_limited(client, app, auth_headers):
def test_rate_limit_is_per_ip(client, app, auth_headers):
"""A second caller (different X-Forwarded-For) has its own budget: one IP
being throttled must not throttle everyone."""
"""A second caller has its own budget: one IP being throttled must not
throttle everyone.
Callers are distinguished by REMOTE_ADDR, the peer that actually connected,
which is what ProxyFix fills from X-Forwarded-For when a proxy is really in
front of this instance.
"""
sha = _store(app, b'per-ip blob')
key = _fetch_key(client, auth_headers)
hot = {**key, 'X-Forwarded-For': '203.0.113.202'}
cool = {**key, 'X-Forwarded-For': '203.0.113.203'}
hot = {'REMOTE_ADDR': '203.0.113.202'}
cool = {'REMOTE_ADDR': '203.0.113.203'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
assert client.get(f'/api/geenforce/payload/{sha}',
headers=hot).status_code == 200
headers=key, environ_base=hot).status_code == 200
assert client.get(f'/api/geenforce/payload/{sha}',
headers=hot).status_code == 429
headers=key, environ_base=hot).status_code == 429
# Fresh IP still gets its first request through.
assert client.get(f'/api/geenforce/payload/{sha}',
headers=cool).status_code == 200
headers=key, environ_base=cool).status_code == 200
def test_rate_limit_cannot_be_reset_by_forging_a_forwarded_header(
client, app, auth_headers):
"""The budget follows the connection, not a header the caller writes.
The limiter used to key on the first X-Forwarded-For hop, so rotating that
header per request bought an unbounded budget and the limit stopped being
a limit.
"""
sha = _store(app, b'forged header blob')
key = _fetch_key(client, auth_headers)
caller = {'REMOTE_ADDR': '203.0.113.210'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
assert client.get(f'/api/geenforce/payload/{sha}', headers=key,
environ_base=caller).status_code == 200
for hop in ('198.51.100.1', '198.51.100.2', '198.51.100.3'):
spoofed = {**key, 'X-Forwarded-For': hop}
assert client.get(f'/api/geenforce/payload/{sha}', headers=spoofed,
environ_base=caller).status_code == 429
def test_rate_limit_can_be_disabled(client, app, auth_headers):

View File

@@ -0,0 +1,101 @@
"""The supplies card must put the emptiest cartridge first.
It sorted on iscritical, then on PRINTER NAME. Critical is everything at or
below 5%, so a cartridge at 5% and one at 0% tied on the first key and the
alphabet decided the order.
That is bad ordering on its own, and worse than it looks: the dashboard queue
takes only the first few rows per source, so a printer that is genuinely empty
was truncated out of the card while a fuller one held its place. The card
existed to answer "what do I walk out and change today" and was answering it
with the alphabet.
"""
from unittest.mock import patch
import pytest
def _call(client, auth_headers):
response = client.get('/api/printers/dashboard/supplies',
headers=auth_headers)
assert response.status_code == 200
return response.get_json()['data']
@pytest.fixture(autouse=True)
def _clear_cache(app):
from shopdb.extensions import cache
cache.delete('printers_low_supplies')
yield
cache.delete('printers_low_supplies')
def _printer(printerid, name, remaining, status='critical'):
return {
'printerid': printerid,
'printername': name,
'assetnumber': 'A{}'.format(printerid),
'location': 'Shop floor',
'mapx': None, 'mapy': None, 'levelid': None,
'supplies': [{
'name': 'Black Toner', 'level': remaining, 'remaining': remaining,
'status': status, 'iswaste': False, 'isdrum': False,
'partnumbers': [], 'lastseen': None, 'color': 'black',
'supplytype': 'toner',
}],
}
def _patched(rows):
return patch('plugins.printers.api.asset_routes._get_low_supplies_data',
return_value={'printers': rows,
'summary': {'total_checked': len(rows),
'low': 0, 'critical': len(rows)}})
def test_the_empty_cartridge_comes_before_the_nearly_empty_one(client,
auth_headers):
"""'Alpha' at 5% used to precede 'Zulu' at 0% purely on the name."""
with _patched([_printer(1, 'Alpha', 5), _printer(2, 'Zulu', 0)]):
data = _call(client, auth_headers)
order = [row['printername'] for row in data['rows']]
assert order == ['Zulu', 'Alpha']
def test_rows_are_ordered_by_how_empty_they_are(client, auth_headers):
with _patched([_printer(1, 'Alpha', 4),
_printer(2, 'Bravo', 0),
_printer(3, 'Charlie', 2),
_printer(4, 'Delta', 5)]):
data = _call(client, auth_headers)
remaining = [row['worstremaining'] for row in data['rows']]
assert remaining == sorted(remaining)
assert remaining == [0, 2, 4, 5]
def test_a_zero_percent_printer_survives_the_queue_truncation(client,
auth_headers):
"""The dashboard queue keeps only the first few rows per source.
With five fuller printers whose names sort earlier, the empty one used to
fall off the end of the card entirely.
"""
rows = [_printer(index, name, 5) for index, name in enumerate(
['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'], start=1)]
rows.append(_printer(6, 'Zulu', 0))
with _patched(rows):
data = _call(client, auth_headers)
# QUEUEPERSOURCE is 5 on the frontend; first place is what matters here.
assert data['rows'][0]['printername'] == 'Zulu'
assert [r['printername'] for r in data['rows'][:5]].count('Zulu') == 1
def test_the_name_still_breaks_a_tie(client, auth_headers):
with _patched([_printer(1, 'Bravo', 3), _printer(2, 'Alpha', 3)]):
data = _call(client, auth_headers)
assert [row['printername'] for row in data['rows']] == ['Alpha', 'Bravo']