geenforce: IP allowlist for client endpoints + admin Settings tab
Fleet PCs on a trusted (vaulted) network can now reach the GE-Enforce client endpoints (manifest, payload, report) without a per-PC token: the auth path accepts a valid geenforce.fetch/report token OR a source IP in the configured allowlist (setting geenforce_allowed_cidrs). Fail-closed; an empty allowlist means the token stays the only path, so existing deployments are unchanged. Rationale: the client token lives in HKLM on every kiosk, so it does not defend against a compromised kiosk anyway - network-perimeter trust is the same practical strength with far less provisioning + no token-rotation churn on a DB wipe. Documented in-UI that this is perimeter trust, not per-device identity. - _ip_allowlisted() (ipaddress, X-Forwarded-For-aware via _client_ip) - /geenforce/config GET/PUT extended with allowedcidrs, server-validated + normalized (bad CIDR -> 400) - new GE-Enforce > Settings tab (GeEnforceSettings.vue) to edit the allowlist in admin, no SQL - 3 regression tests (allow by IP, reject outside list, empty = token required)
This commit is contained in:
@@ -8,6 +8,7 @@ Two audiences:
|
||||
collector's managed-token pattern (X-API-Key or Bearer PAT).
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import time
|
||||
from functools import wraps
|
||||
@@ -39,14 +40,46 @@ geenforce_bp = Blueprint('geenforce', __name__)
|
||||
|
||||
FETCH_SCOPE = 'geenforce.fetch'
|
||||
REPORT_SCOPE = 'geenforce.report'
|
||||
# Comma-separated CIDRs / plain IPs whose callers may reach the client
|
||||
# endpoints WITHOUT a per-PC token (network-trust for a vaulted fleet).
|
||||
# Empty/unset = disabled (token stays the only path).
|
||||
ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs'
|
||||
|
||||
|
||||
def _ip_allowlisted():
|
||||
"""True when the caller IP falls in the configured geenforce allowlist.
|
||||
|
||||
Lets vaulted fleet PCs reach the client endpoints without a per-PC token -
|
||||
network trust replaces the shared secret. Fails closed: an unparseable
|
||||
caller IP or malformed allowlist entry never matches. Empty setting = off.
|
||||
"""
|
||||
raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip()
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(_client_ip())
|
||||
except ValueError:
|
||||
return False
|
||||
for part in raw.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
if ip in ipaddress.ip_network(part, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _require_service_token(scope):
|
||||
"""Decorator factory: require a managed service token scoped for `scope`.
|
||||
"""Decorator factory: require a managed service token scoped for `scope`,
|
||||
OR a caller from the configured IP allowlist.
|
||||
|
||||
Tokens are the only client auth path (the client kit provisions a
|
||||
geenforce.fetch/report token). No env-key fallback: it was never wired into
|
||||
config and an unscoped shared key is a needless backdoor.
|
||||
Two client-auth paths:
|
||||
1. a managed geenforce.fetch/report token (X-API-Key or Bearer PAT), or
|
||||
2. a source IP in geenforce_allowed_cidrs (vault network trust).
|
||||
No env-key fallback. Fail-closed: neither path -> 401.
|
||||
"""
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
@@ -58,6 +91,12 @@ def _require_service_token(scope):
|
||||
# scopes + blobs this token may pull; None = unrestricted).
|
||||
g.geenforce_token = token
|
||||
return f(*args, **kwargs)
|
||||
# Network-trust path: an allowlisted vault IP reaches the client
|
||||
# endpoints with no token. No token = no resource-scope binding
|
||||
# (unrestricted), which the perimeter-trust model accepts.
|
||||
if _ip_allowlisted():
|
||||
g.geenforce_token = None
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
return decorated
|
||||
@@ -702,13 +741,32 @@ def get_version(scopeid, versionnumber):
|
||||
'manifest': _json.loads(version.manifestjson)})
|
||||
|
||||
|
||||
def _normalize_cidrs(raw):
|
||||
"""Validate + normalize a comma/newline-separated CIDR list. Returns
|
||||
(normalized_csv, bad_entries). Bare IPs are accepted (host route)."""
|
||||
good, bad = [], []
|
||||
for part in (raw or '').replace('\n', ',').split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
good.append(str(ipaddress.ip_network(part, strict=False)))
|
||||
except ValueError:
|
||||
bad.append(part)
|
||||
return ','.join(good), bad
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_config():
|
||||
"""Plugin config: the on-share export root (for export-to-share)."""
|
||||
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
return success_response({'shareroot': setting.value if setting else ''})
|
||||
"""Plugin config: the on-share export root + the client IP allowlist."""
|
||||
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
cidrs = Setting.query.filter_by(key=ALLOWED_CIDRS_SETTING).first()
|
||||
return success_response({
|
||||
'shareroot': shareroot.value if shareroot else '',
|
||||
'allowedcidrs': cidrs.value if cidrs else '',
|
||||
})
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['PUT'])
|
||||
@@ -716,11 +774,26 @@ def get_config():
|
||||
@require_permission('geenforce.publish')
|
||||
def put_config():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
|
||||
valuetype='string', category='geenforce',
|
||||
description='On-share export root for GE-Enforce manifests')
|
||||
result = {}
|
||||
if 'shareroot' in payload:
|
||||
shareroot = (payload.get('shareroot') or '').strip()
|
||||
Setting.set(SHAREROOT_SETTING, shareroot, valuetype='string',
|
||||
category='geenforce',
|
||||
description='On-share export root for GE-Enforce manifests')
|
||||
result['shareroot'] = shareroot
|
||||
if 'allowedcidrs' in payload:
|
||||
normalized, bad = _normalize_cidrs(payload.get('allowedcidrs'))
|
||||
if bad:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Invalid CIDR(s): ' + ', '.join(bad), http_code=400)
|
||||
Setting.set(ALLOWED_CIDRS_SETTING, normalized, valuetype='string',
|
||||
category='geenforce',
|
||||
description='Client IP allowlist (CIDRs) that may reach the '
|
||||
'GE-Enforce client endpoints without a token')
|
||||
result['allowedcidrs'] = normalized
|
||||
db.session.commit()
|
||||
return success_response({'shareroot': (payload.get('shareroot') or '').strip()})
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||
|
||||
Reference in New Issue
Block a user