geenforce: display-readiness batch (server hardening, PS client wiring, display scope)

Get GE-Enforce closer to running on credential-less Intune/Entra display PCs
that pull manifest + payloads over HTTPS instead of SMB.

Server (plugins/geenforce/api/routes.py):
- Rate-limit + 512MB served-size ceiling on GET /payload/<sha256> (reuses the
  login limiter's cache pattern, config-overridable via GEENFORCE_PAYLOAD_*).
- New tests: payload hardening, manifestblobs model-vs-migration parity, and a
  report-contract test locking the lowercase per-entry report keys.

PS client (plugins/geenforce/client/):
- Fix New-ShopdbReport per-entry key casing to lowercase (name/action/selfhealed/
  exitcode/message) to match what the server reads; the engine emits PascalCase.
- Enforce TLS 1.2 in the network functions.
- Fetch + merge the fleet-wide common scope alongside the pctype scope
  (pctype wins on conflict; -NoCommon opt-out).
- Normalize whatever the engine returns into a well-formed summary.
- Make the empty-cache fail-safe observable: event-log entry + report ping
  instead of a silent exit 0.

Manifest (plugins/geenforce/seed_display_scope.py + docs/GE-ENFORCE-DISPLAY.md):
- Seed a gea-shopfloor-display scope: 4 Edge kiosk drift-heal registry entries
  + 1 data-driven dispatcher (Dashboard/Lobby/3DPrintRoom via display-type.txt).
  Kiosk EXEs stay image-baked; the manifest heals policy/config drift only.
- Documents the common SMB-payload audit (entries needing http/inline before a
  share-less display can inherit common).

Migration registry (shopdb/plugins/alembic_template.py + test):
- Register the pre-existing manifestblobs and the new printersupplyalerts tables
  in PLUGIN_TABLE_OWNERS; update EXPECTED_HEAD_REVISION for geenforce (0002blobs),
  printers (0002supplyalerts), and printedparts (0004txnrev) which had drifted.
This commit is contained in:
cproudlock
2026-07-23 08:16:38 -04:00
parent b211e817d5
commit 9d65ef103d
12 changed files with 1261 additions and 25 deletions

View File

@@ -9,14 +9,15 @@ Two audiences:
"""
import os
import time
from functools import wraps
from flask import Blueprint, request, Response, send_file
from flask import Blueprint, request, Response, send_file, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
db, cache, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized, Setting, Application,
)
@@ -101,6 +102,59 @@ def get_manifest():
# -- client payload download (share-less installer delivery) ------------------
# GET /payload hardening. This endpoint is reachable with only a read-only
# geenforce.fetch token, so a leaked display token must not be able to pull
# unbounded bytes or hammer it. Two bounds cap the blast radius:
# - a per-IP fixed-window rate limit (same shape and cache extension as the
# login limiter in shopdb.core.api.auth, so no new dependency), and
# - a served-size ceiling: refuse to stream a blob larger than the cap.
# Both are overridable via app config for a site that ships bigger installers.
PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120
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'
def _payload_max_bytes():
return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES',
PAYLOAD_DOWNLOAD_MAX_BYTES)
def _payload_download_ratelimited():
"""Fixed-window per-IP limiter for the payload download endpoint.
Backed by the existing cache extension (no new dependency), same shape as
the login limiter. Under the default SimpleCache the counter is per-process,
so with N gunicorn workers the effective budget is N x the configured max; a
shared cache backend (Redis/memcached) tightens it to a true global budget.
Returns True when the caller is over budget for the current window.
"""
if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True):
return False
window = current_app.config.get(
'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS',
PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS)
maxhits = current_app.config.get(
'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX)
# Time bucket makes this a fixed window: the key rolls over at each window
# boundary, so a per-hit set() cannot turn it into a sliding window.
bucket = int(time.time() // window) if window > 0 else 0
key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}'
count = cache.get(key) or 0
if count >= maxhits:
return True
cache.set(key, count + 1, timeout=window)
return False
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
@require_fetch_token
def get_payload(sha256):
@@ -111,7 +165,15 @@ def get_payload(sha256):
http payloads) first, then an inline DB payload with this hash. The client
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
hash (content is immutable).
Hardened: per-IP rate limited, and a blob over the served-size ceiling is
refused (413) rather than streamed.
"""
if _payload_download_ratelimited():
return error_response('RATE_LIMITED',
'Too many payload downloads. Try again later.',
http_code=429)
sha = (sha256 or '').strip().lower()
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
@@ -120,8 +182,14 @@ def get_payload(sha256):
if request.headers.get('If-None-Match') == etag:
return Response(status=304, headers={'ETag': etag})
maxbytes = _payload_max_bytes()
blob = db.session.get(ManifestBlob, sha)
if blob and os.path.isfile(service.blob_path(sha)):
if blob.sizebytes is not None and blob.sizebytes > maxbytes:
return error_response('PAYLOAD_TOO_LARGE',
'payload exceeds the download size limit',
http_code=413)
response = send_file(
service.blob_path(sha),
mimetype=blob.contenttype or 'application/octet-stream',
@@ -131,6 +199,10 @@ def get_payload(sha256):
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
if inline:
if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes:
return error_response('PAYLOAD_TOO_LARGE',
'payload exceeds the download size limit',
http_code=413)
return Response(
inline.payloadbytes,
mimetype=inline.contenttype or 'application/octet-stream',