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:
@@ -39,11 +39,12 @@ def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# A watched service or process reports a STATE STRING, not a boolean, and the
|
||||
# vocabulary varies by source. Both the services panel and the fleet health
|
||||
# rollup have to agree on what counts as running, so the rule lives here once -
|
||||
# reading it wrong makes every service look stopped.
|
||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
# What counts as RUNNING is defined once, by the reconciler, and imported here.
|
||||
# The panel and the reconciler read the same agent snapshot, so a disagreement
|
||||
# would paint a service green and flag it as drift on the same page. Importing
|
||||
# api -> services is the direction the plugin layout already runs in; the
|
||||
# reconciler's own import of this module is deferred, so there is no cycle.
|
||||
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
|
||||
|
||||
|
||||
def _service_running(entry):
|
||||
@@ -1781,6 +1782,13 @@ OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
|
||||
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
|
||||
# malformed or hostile agent cannot fill the disk with one request.
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
# The multipart envelope carries the other form fields and the part boundaries
|
||||
# too, so content_length is legitimately larger than the image. Allow for that
|
||||
# rather than rejecting a screenshot that is just under the cap.
|
||||
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
|
||||
# PNG signature. The route stores and serves the file as image/png, so this is
|
||||
# the one thing it accepts.
|
||||
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
def _operator_report_dir():
|
||||
@@ -1837,7 +1845,6 @@ def upload_operator_report():
|
||||
holds only metadata plus the stored filename. One retention sweep runs
|
||||
opportunistically so the feed stays bounded.
|
||||
"""
|
||||
from werkzeug.utils import secure_filename
|
||||
import uuid
|
||||
|
||||
error = _collector_auth_or_error()
|
||||
@@ -1862,19 +1869,38 @@ def upload_operator_report():
|
||||
imagemimetype = None
|
||||
imagebytesize = None
|
||||
|
||||
# Refuse an oversized body BEFORE reading it. The cap used to be checked
|
||||
# after upload.read(), which pulls the whole thing into memory first - the
|
||||
# check could not prevent what it was there to prevent. content_length is
|
||||
# caller-supplied so it is a cheap early out, not the enforcement; the read
|
||||
# below is still bounded.
|
||||
declared = request.content_length or 0
|
||||
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot exceeds the {} byte limit'.format(
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||
|
||||
upload = request.files.get('screenshot')
|
||||
if upload is not None and upload.filename:
|
||||
data = upload.read()
|
||||
# Read one byte past the cap: enough to know it was exceeded, without
|
||||
# taking the whole of a body that lied about its length.
|
||||
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
|
||||
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot exceeds the {} byte limit'.format(
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||
if data and not data.startswith(_PNG_MAGIC):
|
||||
# The stored file is served back as image/png. Storing whatever
|
||||
# arrived under that name meant the extension and the bytes could
|
||||
# disagree; the upload headers make the document case inert, but
|
||||
# nothing is gained by keeping a file we already know is not what it
|
||||
# claims.
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot must be a PNG')
|
||||
if data:
|
||||
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
|
||||
if ext != '.png':
|
||||
ext = '.png'
|
||||
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
|
||||
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
|
||||
imagemimetype = 'image/png'
|
||||
imagebytesize = len(data)
|
||||
report_dir = _operator_report_dir()
|
||||
|
||||
Reference in New Issue
Block a user