"""Synthetic native peers only; stdin is private, stdout is bounded metadata.""" import json import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path def run(v): assert {p.name for p in Path('/sys/class/net').iterdir()} == {'lo'} assert len(Path('/proc/net/route').read_text().splitlines()) == 1 opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) cookie = '' target = v.get('target', 'alice') def call(port, path, data=None, *, token=None, auth=False): nonlocal cookie assert port in {17170, 9091} and path.startswith('/') headers = {'Content-Type': 'application/json'} if token: headers['Authorization'] = 'Bearer ' + token if auth: headers.update({'X-Forwarded-Proto': 'https', 'X-Forwarded-Host': 'auth.recovery.test', 'X-Forwarded-For': '127.0.0.1', 'Cookie': cookie}) request = urllib.request.Request( f'http://127.0.0.1:{port}' + path, data=json.dumps(data).encode() if data is not None else None, headers=headers) try: response = opener.open(request, timeout=10) except urllib.error.HTTPError as error: response = error with response: if auth and response.headers.get('Set-Cookie'): cookie = response.headers['Set-Cookie'].split(';', 1)[0] raw = response.read() return response.status, ({} if path == '/health' or response.status >= 400 else json.loads(raw or b'{}')) def ready(port, path): for _ in range(120): try: if call(port, path, auth=port == 9091)[0] == 200: return except (OSError, ValueError): pass time.sleep(.25) raise RuntimeError('peer_startup_' + str(port)) def login(user, password): code, body = call(17170, '/auth/simple/login', {'username': user, 'password': password}) return body.get('token') if code == 200 else None def graphql(token, query, variables=None): code, body = call(17170, '/api/graphql', {'query': query, 'variables': variables or {}}, token=token) assert code == 200 and not body.get('errors'), 'directory_mutation_refused' return body['data'] ready(17170, '/health') admin = login('admin', v['admin_password']) assert admin, 'synthetic_admin_login' if v['mode'] == 'bootstrap': for user in (['alice', 'svc-auth', 'bob', 'operator', 'appoperator', 'svc-other', 'victim'] if v.get('authority_matrix') else ['alice', 'svc-auth']): graphql(admin, 'mutation($u:CreateUserInput!){createUser(user:$u){id}}', { 'u': {'id': user, 'email': user + '@example.test', 'displayName': user}}) if v.get('authority_matrix'): groups = graphql(admin, '{groups{id displayName}}')['groups'] admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin') for user, group in [('operator', admin_id), ('svc-other', next( g['id'] for g in groups if g['displayName'] == 'lldap_strict_readonly'))]: graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup(' 'userId:$u,groupId:$g){ok}}', {'u': user, 'g': group}) app_id = graphql(admin, 'mutation{createGroup(name:"application-admins"){id}}') graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup(' 'userId:$u,groupId:$g){ok}}', {'u': 'appoperator', 'g': app_id['createGroup']['id']}) graphql(admin, 'mutation{createGroup(name:"victim-group"){id}}') return {'status': 'prepared', 'admin_token': admin} if v['mode'] == 'bind_role': groups = graphql(admin, '{groups{id displayName}}')['groups'] memberships = {g['id'] for g in graphql(admin, 'query($u:String!){user(userId:$u){groups{id}}}', {'u': 'svc-auth'})['user']['groups']} for group in ['lldap_strict_readonly', 'lldap_password_manager']: group_id = next(g['id'] for g in groups if g['displayName'] == group) if (group == v['role']) == (group_id in memberships): continue operation = 'addUserToGroup' if group == v['role'] else 'removeUserFromGroup' graphql(admin, 'mutation($u:String!,$g:Int!){' + operation + '(userId:$u,groupId:$g){ok}}', {'u': 'svc-auth', 'g': group_id}) service = login('svc-auth', v['bind_password']) assert service, 'service_directory_login' code, body = call(17170, '/api/graphql', { 'query': 'mutation{createGroup(name:"unexpected-admin-rights"){id}}'}, token=service) assert code >= 400 or body.get('errors'), 'directory_group_creation_refused' return {'status': 'role_set', 'directory_group_creation_refused': True} if v['mode'] == 'authority_context': service = login('svc-auth', v['bind_password']) assert service, 'service_directory_login' return {'status': 'authority_context', 'service_token': service, 'admin_token': admin} if v['mode'] == 'directory_authentication': return {'old_password_accepted': bool(login(v['target'], v['old_password'])), 'new_password_accepted': bool(login(v['target'], v['new_password']))} if v['mode'] == 'write_boundaries': service = login('svc-auth', v['bind_password']) assert service, 'service_directory_login' # Positive controls distinguish an authorization refusal from invalid queries. create_user = 'mutation($u:CreateUserInput!){createUser(user:$u){id}}' update_user = 'mutation($u:UpdateUserInput!){updateUser(user:$u){ok}}' delete_user = 'mutation($u:String!){deleteUser(userId:$u){ok}}' create_group = 'mutation($n:String!){createGroup(name:$n){id}}' delete_group = 'mutation($g:Int!){deleteGroup(groupId:$g){ok}}' add = 'mutation($u:String!,$g:Int!){addUserToGroup(userId:$u,groupId:$g){ok}}' remove = 'mutation($u:String!,$g:Int!){removeUserFromGroup(userId:$u,groupId:$g){ok}}' graphql(admin, create_user, {'u': {'id': 'control', 'email': 'control@example.test'}}) graphql(admin, update_user, {'u': {'id': 'control', 'email': 'changed@example.test'}}) gid = graphql(admin, create_group, {'n': 'control-group'})['createGroup']['id'] graphql(admin, add, {'u': 'control', 'g': gid}) graphql(admin, remove, {'u': 'control', 'g': gid}) graphql(admin, delete_group, {'g': gid}) graphql(admin, delete_user, {'u': 'control'}) snapshot_query = '{users{id email displayName groups{id}} groups{id displayName}}' before = graphql(admin, snapshot_query) groups = before['groups'] admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin') victim_id = next(g['id'] for g in groups if g['displayName'] == 'victim-group') mutations = [ ('create_user', create_user, {'u': {'id': 'unexpected', 'email': 'x@example.test'}}), ('edit_other_user', update_user, {'u': {'id': 'victim', 'email': 'x@example.test'}}), ('delete_user', delete_user, {'u': 'victim'}), ('create_group', create_group, {'n': 'unexpected'}), ('delete_group', delete_group, {'g': victim_id}), ('self_promote_admin', add, {'u': 'svc-auth', 'g': admin_id}), ('remove_admin_membership', remove, {'u': 'operator', 'g': admin_id})] denials = [] for label, query, variables in mutations: code, body = call(17170, '/api/graphql', {'query': query, 'variables': variables}, token=service) assert code >= 400 or body.get('errors'), 'unexpected_directory_write_' + label denials.append(label) assert graphql(admin, snapshot_query) == before, 'directory_metadata_changed' return {'positive_controls': len(mutations), 'refused_mutations': denials, 'directory_metadata_unchanged': True} ready(9091, '/api/health') if v['mode'] == 'reset_finish': code, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True) assert code != 200 or body.get('status') == 'KO', 'reset_without_challenge_refused' code, body = call(9091, '/api/reset-password/identity/finish', {'token': v['reset_token']}, auth=True) assert code == 200 and body.get('status') == 'OK', 'identity_finish' replay, body = call(9091, '/api/reset-password/identity/finish', {'token': v['reset_token']}, auth=True) assert replay != 200 or body.get('status') != 'OK', 'identity_replay_refused' reset, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True) return {'status': ('reset_completed' if reset == 200 and body.get('status') in {None, 'OK'} else 'reset_refused'), 'reset_http_status': reset, 'reset_response_status': body.get('status'), 'native_identity_finish': True, 'reset_without_verified_challenge_refused': True, 'consumed_identity_token_replay_refused': True, 'old_password_accepted_after': bool(login(target, v['old_password'])), 'new_password_accepted_after': bool(login(target, v['new_password']))} assert login(target, v['old_password']), 'initial_password' assert not login(target, v['new_password']), 'replacement_not_initially_accepted' code, body = call(9091, '/api/firstfactor', { 'username': 'svc-auth', 'password': v['bind_password'], 'keepMeLoggedIn': False}, auth=True) assert code != 200 or body.get('status') != 'OK', 'interactive_service_login_refused' code, body = call(9091, '/api/firstfactor', { 'username': target, 'password': v['old_password'], 'keepMeLoggedIn': False}, auth=True) assert code == 200 and body.get('status') == 'OK', 'native_first_factor' cookie = '' code, _ = call(9091, '/api/reset-password/identity/start', {'username': target}, auth=True) assert code == 200, 'reset_start' return {'status': 'reset_requested', 'only_loopback_interface': True, 'external_routes_absent': True, 'old_password_initially_accepted': True, 'new_password_initially_refused': True, 'native_first_factor': True, 'interactive_service_login_refused': True} if __name__ == '__main__': try: print(json.dumps(run(json.load(sys.stdin)))) except Exception as exc: print(json.dumps({'status': 'probe_failed', 'failure_type': type(exc).__name__, 'stage': (str(exc) if isinstance(exc, (AssertionError, RuntimeError)) else 'native_probe')})) raise SystemExit(1)