net-kingdom/sso-mfa/k8s/privacyidea/pi_api.py
tegwick 4dffe2fe7a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
fix: preserve resolver configuration during attended password cutover
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
2026-10-14 01:52:37 +02:00

92 lines
4.2 KiB
Python

"""Captured JSON transport for protected callers; never run against live data to stdout."""
import json
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from mfa_state import NoRedirect, endpoint, unique_object
MAX_BODY = 1024 * 1024
def service_endpoint(value, profile='privacyidea'):
"""Named destinations; profiles never inherit each other's credential scope."""
if profile == 'privacyidea':
return endpoint(value)
hosts = {'lldap-auth': 'lldap.coulomb.social', 'keycape-discovery': 'kc.coulomb.social'}
if profile not in hosts:
raise ValueError()
if value in ('https://' + hosts[profile], 'https://' + hosts[profile] + ':443'):
return value
base = endpoint(value)
if urllib.parse.urlsplit(base).hostname not in ('127.0.0.1', '::1'):
raise ValueError()
return base
def request(url, payload=None, token=None, *, method=None, profile='privacyidea'):
try:
parsed = urllib.parse.urlsplit(url)
base = service_endpoint(urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', '')), profile)
if parsed.fragment or not parsed.path.startswith('/') or any(ord(c) < 32 for c in url):
return 0, None
verb = method or ('POST' if payload is not None else 'GET')
if profile != 'privacyidea':
if token is not None or parsed.query: return 0, None
if profile == 'lldap-auth':
if (verb != 'POST' or parsed.path != '/auth/simple/login' or not isinstance(payload, dict)
or set(payload) != {'username', 'password'} or payload['username'] != 'admin'
or not isinstance(payload['password'], str) or not payload['password']): return 0, None
elif verb != 'GET' or parsed.path != '/.well-known/openid-configuration' or payload is not None:
return 0, None
# Origin validated before creating a request containing protected values.
headers = {'Accept': 'application/json'}
data = None
if payload is not None:
data = json.dumps(payload).encode('utf-8')
if len(data) > MAX_BODY: return 0, None
headers['Content-Type'] = 'application/json'
if token:
if not isinstance(token, str) or len(token) > 16384 or any(ord(c) < 33 or ord(c) > 126 for c in token):
return 0, None
headers['Authorization'] = token
req = urllib.request.Request(base + parsed.path + ('?' + parsed.query if parsed.query else ''),
data=data, headers=headers, method=method or ('POST' if payload is not None else 'GET'))
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(),
urllib.request.HTTPSHandler(context=context))
with opener.open(req, timeout=20) as response:
status, raw = response.status, response.read(MAX_BODY + 1)
if len(raw) > MAX_BODY: return status, None
return status, json.loads(raw, object_pairs_hook=unique_object) if raw else None
except urllib.error.HTTPError as exc:
exc.close()
return exc.code, None
except Exception:
# Callers emit fixed phases, never exception strings or response bodies.
return 0, None
def main():
try:
if len(sys.argv) != 3 or sys.argv[1] not in ('GET', 'POST'):
raise ValueError()
token = sys.stdin.readline(16386).removesuffix('\n')
body = sys.stdin.read(MAX_BODY + 1)
if len(body) > MAX_BODY: raise ValueError()
status, result = request(sys.argv[2], json.loads(body, object_pairs_hook=unique_object) if body else None,
token or None, method=sys.argv[1])
if not 200 <= status < 300 or not isinstance(result, dict): raise ValueError()
# Private shell adapter: caller must capture this; it can contain a JWT/config.
print(json.dumps(result))
return 0
except Exception:
print('CURL_FAILED')
return 1
if __name__ == '__main__':
raise SystemExit(main())