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:
cproudlock
2026-07-27 14:06:40 -04:00
parent 19876a5640
commit 0860aa85c5
5 changed files with 232 additions and 11 deletions

View File

@@ -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'])

View File

@@ -24,6 +24,12 @@ export default [
name: 'geenforce-reports',
component: () => import('./views/EnforcementReports.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
},
{
path: 'settings',
name: 'geenforce-settings',
component: () => import('./views/GeEnforceSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
}
]
}

View File

@@ -7,6 +7,7 @@
<nav class="section-tabs">
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
<router-link to="/geenforce/reports" class="tab">Enforcement Reports</router-link>
<router-link to="/geenforce/settings" class="tab">Settings</router-link>
</nav>
<router-view />
</div>

View File

@@ -0,0 +1,109 @@
<template>
<div class="geenforce-settings">
<h2>Client Access</h2>
<p class="muted">
Fleet PCs on a trusted network can reach the GE-Enforce client endpoints
(manifest, payload, report) without a per-PC token. List the trusted
networks below. Leave it empty to require a <code>geenforce.fetch</code>
token instead.
</p>
<label class="field-label" for="cidrs">Allowed networks (CIDR)</label>
<textarea
id="cidrs"
v-model="cidrs"
class="cidr-input"
rows="5"
spellcheck="false"
placeholder="10.134.48.0/23&#10;10.48.249.0/26"
></textarea>
<small class="input-hint">
One CIDR (or a plain IP) per line, or comma-separated. Example:
<code>10.134.48.0/23, 10.48.249.0/26</code>. A caller from any of these
networks may pull manifests + payloads and post reports with no token.
</small>
<div class="actions">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<span v-if="message" :class="['status', ok ? 'ok' : 'err']">{{ message }}</span>
</div>
<div class="note">
<strong>Note:</strong> this is network-perimeter trust. Any host on a
listed network is trusted - including a compromised one. It does not
replace per-device identity if you need tamper-proof report integrity.
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const cidrs = ref('')
const saving = ref(false)
const message = ref('')
const ok = ref(false)
function unwrap(response) { return response.data.data }
async function load() {
try {
const config = unwrap(await api.get('/geenforce/config'))
cidrs.value = config.allowedcidrs || ''
} catch (e) {
message.value = 'Could not load config.'
ok.value = false
}
}
async function save() {
saving.value = true
message.value = ''
try {
const config = unwrap(await api.put('/geenforce/config', { allowedcidrs: cidrs.value }))
cidrs.value = config.allowedcidrs || '' // server returns the normalized list
ok.value = true
message.value = 'Saved.'
} catch (e) {
ok.value = false
message.value = e?.response?.data?.data?.error?.message || 'Save failed.'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.geenforce-settings { max-width: 640px; }
.muted { color: var(--text-light); }
.field-label { display: block; font-weight: 600; margin: 1rem 0 0.35rem; }
.cidr-input {
width: 100%;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 0.9rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--bg-card-solid);
color: var(--text);
resize: vertical;
}
.input-hint { display: block; color: var(--text-light); margin-top: 0.35rem; }
.actions { display: flex; align-items: center; gap: 0.75rem; margin-top: 1rem; }
.status.ok { color: var(--success); font-weight: 600; }
.status.err { color: var(--danger); font-weight: 600; }
.note {
margin-top: 1.5rem;
padding: 0.75rem 1rem;
border-left: 4px solid var(--warning);
background: var(--bg-card);
border-radius: 0.25rem;
color: var(--text-light);
font-size: 0.9rem;
}
</style>

View File

@@ -115,6 +115,38 @@ def test_unauthenticated_rejected(client, db, app):
assert resp.status_code == 401
def _set_allowlist(app, value):
from shopdb.api import Setting
with app.app_context():
Setting.set('geenforce_allowed_cidrs', value, 'string', 'geenforce')
service.db.session.commit()
def test_ip_allowlist_allows_without_token(client, db, app):
# An allowlisted caller reaches the manifest with NO token (vault trust).
_seed_and_publish(app)
_set_allowlist(app, '127.0.0.0/8, 10.134.48.0/23') # test client is 127.0.0.1
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 200, resp.data
assert b'Alpha' in resp.data
def test_ip_not_in_allowlist_still_rejected(client, db, app):
# A caller outside the allowlist and with no token is refused (fail-closed).
_seed_and_publish(app)
_set_allowlist(app, '10.0.0.0/8') # test client 127.0.0.1 is NOT in 10/8
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 401
def test_empty_allowlist_keeps_token_required(client, db, app):
# Empty/unset allowlist = disabled; token stays the only path (back-compat).
_seed_and_publish(app)
_set_allowlist(app, '')
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 401
def test_wrong_scope_rejected(client, db, app, auth_headers):
_seed_and_publish(app)
resp = client.post('/api/apitokens',