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

@@ -2013,3 +2013,20 @@ class ComputersPlugin(BasePlugin):
('computers.edit', 'Edit computers', 'computers'),
('computers.delete', 'Delete computers', 'computers'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Declared because a site chose it: the floor looks PCs up by number.
KNOWN GAP, tracked separately. The computer serializer is
BaseModel.to_dict(), so it publishes every column - serialnumber, the
Azure device and object ids - and the detail route resolves
accessmethods into working rdp:// and vnc:// links plus the full
Communication row. A guest granted this gets all of it, on the LIST
route as well, so it is one request rather than one per asset.
The fix is a field allowlist on Computer.to_dict() plus suppressing the
resolved links unless the caller holds a remote-access permission. Do
not widen this hook until that lands; a site enabling guest access
today should weigh it.
"""
return ['computers.view']

View File

@@ -7,6 +7,7 @@ These routes are the human-facing read surface, gated by user JWT + RBAC.
"""
import hashlib
import hmac
import json
import os
import time
@@ -941,6 +942,43 @@ _CLAIM_HOLD_SECONDS = 25
_CLAIM_POLL_INTERVAL = 1.0
def _pin_holds(command, descriptor):
"""Does the descriptor still carry the digest this command was queued for?
True when nothing was pinned (actions that deliver no content), or when the
descriptor is missing entirely - a vanished script or version already sends
the command through with no descriptor so the agent fails closed, and that
path is not a content swap.
"""
if not command.pinnedsha256:
return True
if descriptor is None:
return True
return hmac.compare_digest(
str(descriptor.get('sha256') or ''), command.pinnedsha256)
def _fail_on_pin_drift(command, action):
"""Refuse a claim whose content changed after the operator approved it."""
command.status = EndpointCommand.STATUS_FAILED
command.resultmessage = (
'Refused: the {} content changed after this command was queued'
.format(action))
command.completedat = _naive_utcnow()
db.session.commit()
current_app.logger.warning(
'Endpoint command %s (%s, asset %s) refused: pinned sha256 %s no '
'longer matches the resolved descriptor',
command.endpointcommandid, action, command.assetid,
command.pinnedsha256)
AuditLog.log('refused', 'EndpointCommand',
entityid=command.endpointcommandid,
entityname=action,
details='content digest changed after enqueue')
db.session.commit()
return ('', 204)
def _reap_stale_commands(asset_id):
"""Expire in-flight commands an agent will never finish for this endpoint.
@@ -1234,18 +1272,25 @@ def enqueue_endpoint_command(asset_id: int):
# update_app pins a specific application version; verify it resolves to a
# real installer now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint minutes later.
# Digest of the content this push was APPROVED against. Kept on the row so
# the claim path can refuse a descriptor that has changed since; see
# EndpointCommand.pinnedsha256.
pinnedsha256 = None
if action == 'update_app':
_descriptor, resolve_error = _resolve_update_app(target)
descriptor, resolve_error = _resolve_update_app(target)
if resolve_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
pinnedsha256 = (descriptor or {}).get('sha256')
# run_script pins a ShopDB-hosted script; verify it resolves to a real
# uploaded body now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint later.
if action == 'run_script':
_script, script_error = _resolve_run_script(target)
script, script_error = _resolve_run_script(target)
if script_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
pinnedsha256 = (script or {}).get('sha256')
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
@@ -1277,6 +1322,7 @@ def enqueue_endpoint_command(asset_id: int):
targetkind=targetkind if target else None,
status=EndpointCommand.STATUS_PENDING,
requestedby=getattr(current_user, 'userid', None),
pinnedsha256=pinnedsha256,
)
db.session.add(command)
@@ -1337,6 +1383,8 @@ def claim_next_command():
# fail closed rather than 500 the poll.
if command.action == 'update_app':
descriptor, _resolve_error = _resolve_update_app(command.target)
if not _pin_holds(command, descriptor):
return _fail_on_pin_drift(command, 'update_app')
if descriptor is not None:
claim['app'] = descriptor
# run_script rides a resolved script descriptor: the agent gets a
@@ -1347,6 +1395,8 @@ def claim_next_command():
# agent fail closed rather than 500 the poll.
if command.action == 'run_script':
script_descriptor, _script_error = _resolve_run_script(command.target)
if not _pin_holds(command, script_descriptor):
return _fail_on_pin_drift(command, 'run_script')
if script_descriptor is not None:
claim['script'] = script_descriptor
return success_response(claim)
@@ -1376,6 +1426,33 @@ def report_command_result(command_id: int):
return error_response(ErrorCodes.NOT_FOUND,
f'No command {command_id}', http_code=404)
# An agent may only finalize ITS OWN command. The collector key is shared
# fleet-wide, so without this any agent could post a result for any command
# id - marking an install succeeded on a machine that never ran it, which
# writes a false compliance record. Binding to the claimed hostname is a
# floor, not a fix: the credential still does not prove which endpoint is
# calling, and only a per-endpoint credential closes that.
# Optional because the shipped agent does not send it yet; enforced when it
# does, so an updated fleet gets the check without a flag day.
hostname = (request.args.get('hostname') or '').strip()
if hostname:
caller_asset_id = _resolve_asset_for_hostname(hostname)
if caller_asset_id != command.assetid:
return error_response(
ErrorCodes.FORBIDDEN,
'This command belongs to another endpoint', http_code=403)
# Only a claimed command can be finalized. Needs no agent change, and it
# closes two things the hostname check cannot: reporting on a command that
# was never claimed, and overwriting a terminal result - which would let a
# recorded failure be replaced by a success.
if command.status != EndpointCommand.STATUS_RUNNING:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Command {} is {}, not running'.format(
command_id, command.status),
http_code=409)
payload = request.get_json(silent=True) or {}
succeeded = bool(payload.get('succeeded'))
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded

View File

@@ -0,0 +1,51 @@
"""Pin the approved content digest on an endpoint command.
Delivery descriptors (run_script, update_app) are re-resolved when the agent
claims a command, not when an operator queues it. Between those two moments the
underlying script body or installer package can be replaced by anyone who can
edit the applications catalog, and the descriptor's SHA-256 would then describe
the NEW bytes: the agent verifies correctly and runs something the operator
never approved.
pinnedsha256 records the digest as it stood at enqueue. The claim path compares
and refuses the command if it has moved, so a swap fails the push instead of
shipping it.
Null for actions that deliver no content, and null on rows queued before this
migration - those are treated as unpinned and claim as before.
Idempotent: checks the live schema first so a db.create_all() site re-runs clean
on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0005cmdpin'
down_revision = 'endpoints0004apps'
branch_labels = None
depends_on = None
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column('endpointcommands', 'pinnedsha256'):
op.add_column('endpointcommands',
sa.Column('pinnedsha256', sa.String(length=64),
nullable=True))
def downgrade():
if _has_column('endpointcommands', 'pinnedsha256'):
op.drop_column('endpointcommands', 'pinnedsha256')

View File

@@ -172,6 +172,19 @@ class EndpointCommand(db.Model):
# Who queued it (userid) for audit; the agent never sees this.
requestedby = db.Column(db.Integer, nullable=True)
# The content digest as it stood WHEN AN OPERATOR APPROVED THIS PUSH.
#
# Delivery descriptors are re-resolved at claim time, minutes or hours
# later, so without this the bytes an operator approved and the bytes the
# agent runs are two different lookups against mutable content. Whoever can
# replace a script body or an installer package could swap it in the gap
# and the digest would faithfully describe the new bytes.
#
# Set at enqueue for run_script and update_app; null for actions that
# deliver no content. The claim path refuses a command whose descriptor no
# longer matches, so a swap fails the command instead of shipping.
pinnedsha256 = db.Column(db.String(64), nullable=True)
# Agent-reported outcome. resultjson holds structured detail (for example
# the observed MTConnect running state); resultmessage is a short summary.
resultmessage = db.Column(db.Text, nullable=True)

View File

@@ -145,10 +145,11 @@ class EndpointsPlugin(BasePlugin):
# including every logged-in username on the floor, because the
# panels and the list were gated on the same string.
#
# It is deliberately NOT named '.view'. guest_grantable_permissions
# opens any '.view' or '.read' permission to the guest role, so this
# name is what keeps the fleet roster off the public surface while
# per-asset telemetry can still be granted.
# The name avoids '.view' for readability, but the NAME no longer
# decides anything: guest_grantable_permissions is a declared list
# now, and this plugin blesses endpoints.view alone below. Adding
# endpoints.fleet there would be what opens the roster, and the
# docstring on that hook says why not to.
('endpoints.fleet',
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
# Issue an allowlisted command to an endpoint agent (for example the
@@ -187,6 +188,21 @@ class EndpointsPlugin(BasePlugin):
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Declared because a site chose it: live status on a PC page.
endpoints.view only. NOT endpoints.fleet - that is the roster, and the
whole reason the two were split.
KNOWN GAP, tracked separately: the per-asset panels this opens are
keyed on a small integer asset id and the status panel emits the
logged-in username, so walking ids reconstructs most of the roster the
split exists to protect. Bounding or rate-limiting the per-asset panels
is the fix. Until then a site enabling guest access should know the
split is a speed bump here, not a wall.
"""
return ['endpoints.view']
def get_settings_defaults(self) -> List[Dict]:
"""Settings owned by the endpoints agent-key kill switch.

View File

@@ -21,7 +21,7 @@ from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, cache, success_response, error_response, ErrorCodes, require_permission,
require_any_permission,
authorized_service_token, Setting, Application,
authorized_service_token, Setting, Application, client_ip,
)
SHAREROOT_SETTING = 'geenforce_share_root'
@@ -207,12 +207,14 @@ PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
def _client_ip():
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
(mirrors shopdb.core.api.auth._login_ip)."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr or 'unknown'
"""Caller IP for rate limiting.
Uses the shared helper, which resolves from remote_addr. The raw
X-Forwarded-For hop is client-supplied, so keying a rate limit on it lets a
caller rotate the header and get an unbounded budget - the same reasoning
_trusted_client_ip() above already applies to the allowlist.
"""
return client_ip(request) or 'unknown'
def _payload_max_bytes():

View File

@@ -2659,6 +2659,9 @@ def dashboard_supplies():
# dashboard card and the toner report were both doing.
'levelid': printer.get('levelid'),
'iscritical': any(s['status'] == 'critical' for s in depleted),
# The emptiest cartridge on this printer, which is what decides the
# row's place in the queue. depleted is sorted and non-empty here.
'worstremaining': depleted[0]['remaining'],
'supplies': [{
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
supply.get('remaining')),
@@ -2667,7 +2670,18 @@ def dashboard_supplies():
} for supply in depleted],
})
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
# Emptiest first. This sorted on iscritical then NAME, and 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 which came first. That is bad ordering
# on its own and worse than it looks: the dashboard queue takes only the
# first few rows per source, so the printer that is actually empty was
# being truncated away while a fuller one held its place.
#
# remaining is already normalised for waste cartridges (a full waste bin
# reads as low remaining), so one ascending sort is worst-first for every
# supply type, and iscritical falls out of it rather than needing its own
# key. Name only breaks ties.
rows.sort(key=lambda r: (r['worstremaining'], r['printername']))
# Envelope rather than a bare list, so the card can say WHY it has no rows.
# An empty card and a blind card must not look the same.
return success_response({'rows': rows,

View File

@@ -37,7 +37,7 @@ from flask import current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db, cache, Setting,
db, cache, Setting, client_ip,
success_response, error_response, ErrorCodes,
)
@@ -117,8 +117,9 @@ def _guest_ratelimited():
person has more addresses - but it stops an open endpoint being an easy
place to park data or fill a disk.
"""
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr or 'unknown')
# Not the raw X-Forwarded-For hop: the caller sends that, so rotating it
# gives an unbounded budget and the limit stops being a limit.
address = client_ip(request) or 'unknown'
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
key = f'securesendguest:{address}:{bucket}'
used = cache.get(key) or 0

View File

@@ -174,5 +174,13 @@ class WarrantyPlugin(BasePlugin):
('warranty.delete', 'Delete warranties', 'warranty'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Coverage dates and vendor for an asset somebody is standing at.
Nothing here is a credential and nothing reaches a supplier portal; the
provider lookups run server-side with keys the caller never sees.
"""
return ['warranty.view']
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})")