printers low-toner alerts; geenforce display readiness + token scope-binding; air-gap docker deploy kit
This commit is contained in:
@@ -9,15 +9,16 @@ 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, g
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized, Setting, Application,
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
authorized_service_token, Setting, Application,
|
||||
)
|
||||
|
||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||
@@ -50,7 +51,12 @@ def _require_service_token(scope):
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(scope):
|
||||
token = authorized_service_token(scope)
|
||||
if token is not None:
|
||||
# Stash for the route so it can honor the token's resource
|
||||
# binding (token.resourcescopelist restricts which manifest
|
||||
# scopes + blobs this token may pull; None = unrestricted).
|
||||
g.geenforce_token = token
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
@@ -58,6 +64,14 @@ def _require_service_token(scope):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _token_resource_scopes():
|
||||
"""Resource-scope allowlist for the authorized token, or None if the token
|
||||
may reach any scope. A bound token (e.g. a display's) is pinned to its own
|
||||
manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
|
||||
token = getattr(g, 'geenforce_token', None)
|
||||
return token.resourcescopelist if token is not None else None
|
||||
|
||||
|
||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||
|
||||
@@ -79,6 +93,11 @@ def get_manifest():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and scopename not in allowed:
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'token is not allowed this scope', http_code=403)
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
@@ -101,6 +120,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,17 +183,40 @@ 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',
|
||||
http_code=400)
|
||||
|
||||
# A resource-bound token may only pull a blob its own scope(s) reference.
|
||||
# Return 404 (not 403) so it cannot probe which hashes exist. Checked before
|
||||
# the 304 shortcut so a bound token cannot even confirm a hash via ETag.
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
|
||||
http_code=404)
|
||||
|
||||
etag = f'"{sha}"'
|
||||
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 +226,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',
|
||||
|
||||
Reference in New Issue
Block a user