Close the four smaller findings from the wilm import review

None blocked the merge; all four were mechanisms that did not mechanise.

_RUNNING_STATES existed twice, in api/routes.py and services/reconciler.py, with
a comment asking a human to keep them in lockstep. The panel and the reconciler
read the same agent snapshot, so a drift between the copies would paint a
service green and flag it as drift on the same page. One definition now, owned
by the reconciler and imported by routes - api -> services is the direction the
plugin layout already runs in, and the reconciler's import of routes is deferred,
so there is no cycle.

The operator-report upload checked its 10 MB cap AFTER upload.read() had already
pulled the whole body into memory, so the check could not prevent the thing it
was there to prevent. content_length is now an early out and the read itself is
bounded to cap+1, which is enough to know the cap was exceeded without taking a
body that lied about its length. The multipart envelope carries the other form
fields too, so the early out allows 64 KB of slack rather than rejecting an
image that is just under.

The same handler forced the extension to .png and the mimetype to image/png
whatever the bytes were, then served the file back under that type. The upload
headers (CSP sandbox + nosniff) make the document case inert, so this was never
the hole it looks like, but nothing is gained by storing a file we already know
is not what it claims. The PNG signature is now checked.

The lightbox bound Escape and the arrow keys to a div and never focused it, so
every one of those bindings was dead until the user happened to click the
backdrop. It takes focus on open and hands it back to the thumbnail on close.

On the two upload tests, honestly: the PNG one was confirmed to fail with the
check removed. The oversize one passes either way, because the old code did
reject an oversized image - it just did so after reading all of it. That test
pins the response, not the fix. Whether the read is bounded is not observable
through the response, so nothing here proves it; the change stands on reading
the handler.

Suite 2311 passing, frontend 316 passing, build clean.

Scope: product
This commit is contained in:
cproudlock
2026-09-02 15:07:03 -04:00
parent d45a0d15b4
commit 720f4ddbb2
4 changed files with 113 additions and 19 deletions

View File

@@ -235,3 +235,51 @@ def test_retention_disabled_keeps_old_reports(client, db, app):
headers=_collector_headers(app))
assert db.session.get(OperatorReport, old.operatorreportid) is not None
# --- what the route refuses to store -----------------------------------------
#
# The stored file is served back as image/png, and the size cap is the only
# thing bounding what an agent key can push into the instance directory. Both
# guards used to be nominal: the cap was checked AFTER upload.read() had already
# pulled the whole body into memory, and the extension was forced to .png
# whatever the bytes were.
def test_upload_refuses_bytes_that_are_not_a_png(client, db, app):
from plugins.endpoints.models import OperatorReport
_computer(db, 'OPREPORT-NOTPNG')
data = {
'hostname': 'OPREPORT-NOTPNG',
'details': 'not really a screenshot',
'screenshot': (io.BytesIO(b'MZ\x90\x00 this is a PE, not an image'),
'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
# Nothing was written: not the row, and not a file under the uploads dir.
assert OperatorReport.query.filter_by(
reportedhostname='OPREPORT-NOTPNG').first() is None
def test_upload_refuses_an_image_over_the_cap(client, db, app):
from plugins.endpoints.api.routes import OPERATOR_REPORT_MAX_IMAGE_BYTES
from plugins.endpoints.models import OperatorReport
_computer(db, 'OPREPORT-HUGE')
# A real PNG header so the size check is what rejects it, not the signature.
oversized = _png_bytes() + b'\x00' * (OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
data = {
'hostname': 'OPREPORT-HUGE',
'details': 'oversized',
'screenshot': (io.BytesIO(oversized), 'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
assert 'limit' in response.get_json()['data']['error']['message']
assert OperatorReport.query.filter_by(
reportedhostname='OPREPORT-HUGE').first() is None