"""Diagnostic regressions: private inputs, actual HTTP transport, fail-closed counts.""" import base64 import copy import importlib.util import json import os from pathlib import Path import subprocess import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlsplit import pytest ROOT = Path(__file__).resolve().parents[1] HELPERS = ROOT / 'sso-mfa/k8s/privacyidea' spec = importlib.util.spec_from_file_location('mfa_state', HELPERS / 'mfa_state.py') m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) def jwt(**changes): claims = {'role': 'admin', 'exp': time.time() + 300, **changes} encoded = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b'=').decode() return 'eyJhbGciOiJIUzI1NiJ9.' + encoded + '.syntheticSignature' def page(rows): return {'tokens': rows, 'count': len(rows), 'current': 1, 'next': None, 'prev': None} def factor(serial='synthetic-factor', **changes): return {'serial': serial, 'active': True, 'user_id': '1001', 'username': 'alice', 'user_realm': 'coulomb', 'realms': ['coulomb'], 'rollout_state': 'enrolled', **changes} def probe(**changes): return factor(m.PROBE, active=False, user_id='', username='', **changes) @pytest.fixture def server(): state = {'user': page([factor()]), 'proof': page([probe()]), 'requests': [], 'calls': 0} class Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass def do_GET(self): state['requests'].append((self.path, dict(self.headers))) state['calls'] += 1 if state.get('status'): self.send_response(state['status']) self.send_header('Location', state.get('location', '/stolen')) self.end_headers(); self.wfile.write(b'private-provider-message'); return query = parse_qs(urlsplit(self.path).query) value = state['proof'] if 'serial' in query else state['user'] if state.get('withdraw_after_user') and state['calls'] >= 3: value = page([]) body = json.dumps({'result': {'status': True, 'value': value}}).encode() self.send_response(200); self.end_headers(); self.wfile.write(state.get('raw', body)) s = ThreadingHTTPServer(('127.0.0.1', 0), Handler) t = threading.Thread(target=s.serve_forever, daemon=True); t.start() try: yield f'http://127.0.0.1:{s.server_port}', state finally: s.shutdown(); s.server_close(); t.join() @pytest.fixture def token_file(tmp_path): private = tmp_path / 'private'; private.mkdir(mode=0o700) path = private / 'input'; path.write_text(jwt() + '\n'); path.chmod(0o600) return path def invoke(token_file, url, *extra): return subprocess.run(['bash', '-x', str(HELPERS / 'check-user-mfa-state.sh'), 'alice', '--token-file', str(token_file), '--pi-url', url, *extra], capture_output=True, text=True, timeout=15) def test_real_http_cli_reports_complete_private_counts(server, token_file, monkeypatch): url, state = server state['user'] = page([factor(str(i)) for i in range(18)] + [factor('pending', rollout_state='verify'), factor('inactive', active=False)]) monkeypatch.setenv('http_proxy', 'http://127.0.0.1:1'); monkeypatch.setenv('HTTP_PROXY', 'http://127.0.0.1:1') monkeypatch.setenv('no_proxy', ''); monkeypatch.setenv('NO_PROXY', '') p = invoke(token_file, url) assert p.returncode == 0, p.stderr data = json.loads(p.stdout) assert data['factors'] == {'total': 20, 'active_confirmed': 18, 'active_pending': 1, 'inactive': 1} assert data['user_existence'] == 'not_checked' and data['mfa_login'] == 'not_tested' assert len(state['requests']) == 3 for path, headers in state['requests']: assert urlsplit(path).path == '/token/' and 'Content-Type' not in headers assert headers['Authorization'] == token_file.read_text().strip() assert token_file.read_text().strip() not in p.stdout + p.stderr assert str(token_file) not in p.stdout + p.stderr assert 'synthetic-factor' not in p.stdout @pytest.mark.parametrize('mutation', ['empty', 'assigned', 'active', 'realm', 'serial', 'count', 'withdraw']) def test_scope_uncertainty_never_reports_zero(server, mutation): url, state = server; state['user'] = page([]) if mutation == 'empty': state['proof'] = page([]) elif mutation == 'assigned': state['proof']['tokens'][0]['user_id'] = 'someone' elif mutation == 'active': state['proof']['tokens'][0]['active'] = True elif mutation == 'realm': state['proof']['tokens'][0]['realms'] = ['other'] elif mutation == 'serial': state['proof']['tokens'][0]['serial'] = 'other' elif mutation == 'count': state['proof']['count'] = 2 elif mutation == 'withdraw': state['withdraw_after_user'] = True with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), 'alice') def test_empty_verified_result_is_not_proof_user_exists(server): url, state = server; state['user'] = page([]) data = m.diagnose(m.Client(url, jwt()), 'alice') assert data['factors']['total'] == 0 and data['user_existence'] == 'not_checked' @pytest.mark.parametrize('mutation', ['truncated', 'duplicate', 'next', 'count-bool', 'current-bool', 'wrong-user', 'wrong-realm', 'missing-active', 'missing-state', 'unknown-state']) def test_malformed_or_incomplete_user_result_refused(server, mutation): url, state = server; value = state['user']; row = value['tokens'][0] if mutation == 'truncated': value['count'] = 20 elif mutation == 'duplicate': value['tokens'].append(copy.deepcopy(row)); value['count'] = 2 elif mutation == 'next': value['next'] = 2 elif mutation == 'count-bool': value['count'] = True elif mutation == 'current-bool': value['current'] = True elif mutation == 'wrong-user': row['username'] = 'bob' elif mutation == 'wrong-realm': row['user_realm'] = 'other' elif mutation == 'missing-active': row.pop('active') elif mutation == 'missing-state': row.pop('rollout_state') elif mutation == 'unknown-state': row['rollout_state'] = 'new-unreviewed-state' with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), 'alice') @pytest.mark.parametrize('mode', ['public', 'symlink', 'hardlink', 'fifo', 'directory', 'public-parent', 'bad-jwt', 'self-service', 'expired', 'oversized', 'inside-git']) def test_bad_input_precedes_any_network(server, token_file, mode): url, state = server if mode == 'public': token_file.chmod(0o644) elif mode == 'symlink': token_file.rename(token_file.with_suffix('.real')); token_file.symlink_to(token_file.with_suffix('.real')) elif mode == 'hardlink': os.link(token_file, token_file.with_suffix('.link')) elif mode == 'fifo': token_file.unlink(); os.mkfifo(token_file, 0o600) elif mode == 'directory': token_file.unlink(); token_file.mkdir(mode=0o700) elif mode == 'public-parent': token_file.parent.chmod(0o755) elif mode == 'bad-jwt': token_file.write_text('private-secret-not-a-jwt') elif mode == 'self-service': token_file.write_text(jwt(role='user')) elif mode == 'expired': token_file.write_text(jwt(exp=1)) elif mode == 'oversized': token_file.write_bytes(b'x' * 16386) elif mode == 'inside-git': (token_file.parent / '.git').mkdir(); (token_file.parent / '.git/HEAD').write_text('ref: refs/heads/main') p = invoke(token_file, url) assert p.returncode == 1 and not p.stdout and 'unverified' in p.stderr assert 'private-secret' not in p.stderr and not state['requests'] @pytest.mark.parametrize('url', ['https://attacker.invalid', 'http://pink.coulomb.social', 'http://localhost:8000', 'http://127.0.0.1:8000/path', 'http://127.0.0.1:8000?x=1', 'https://user:password@pink.coulomb.social', 'http://127.1:8000']) def test_destination_is_checked_before_credential(url, tmp_path): p = invoke(tmp_path/'absent', url) assert p.returncode == 1 and not p.stdout and 'password' not in p.stderr @pytest.mark.parametrize('status', [301, 302, 307, 308, 401, 403, 500]) def test_no_redirect_or_provider_error_leak(server, token_file, status): url, state = server; state['status'] = status p = invoke(token_file, url) assert p.returncode == 1 and not p.stdout assert len(state['requests']) == 1 and 'private-provider-message' not in p.stderr @pytest.mark.parametrize('raw', [b'{"result":{"status":false,"error":{"message":"private-provider-message"}}}', b'{"result":{"status":true,"status":false}}', b'not-json', b'x'*(m.MAX_BODY+1)], ids=['error', 'duplicate', 'invalid', 'oversized']) def test_bad_provider_body_is_sanitized(server, token_file, raw): url, state = server; state['raw'] = raw p = invoke(token_file, url) assert p.returncode == 1 and not p.stdout and 'private-provider-message' not in p.stderr def test_unknown_argument_is_not_echoed(server, token_file): url, state = server p = invoke(token_file, url, '--unknown=private-argument') assert p.returncode == 1 and 'private-argument' not in p.stderr and not state['requests'] @pytest.mark.parametrize('username', ['alice&realm=other', '*', 'alice@other', '../alice']) def test_provider_expansion_is_refused_before_request(server, username): url, state = server with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), username) assert not state['requests'] def test_real_privacyidea_contract(): pytest.importorskip('privacyidea') p = subprocess.run([sys.executable, str(ROOT/'tests/mfa_state_native_fixture.py')], capture_output=True, text=True, timeout=180) assert p.returncode == 0, p.stdout data = json.loads(p.stdout) assert len(data) == 7 and all(v is True for v in data.values()) def test_untrusted_https_is_not_accepted_via_ambient_ca(tmp_path, token_file, monkeypatch): import datetime import ipaddress import ssl from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID key = rsa.generate_private_key(public_exponent=65537, key_size=2048) name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'fixture')]) now = datetime.datetime.now(datetime.timezone.utc) cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) .serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=1)) .not_valid_after(now+datetime.timedelta(hours=1)) .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), False) .sign(key, hashes.SHA256())) cert_path = tmp_path/'cert.pem'; key_path = tmp_path/'key.pem' cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) key_path.chmod(0o600) seen = [] class Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass def do_GET(self): seen.append(dict(self.headers)); self.send_error(500) server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path) server.socket = context.wrap_socket(server.socket, server_side=True) thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() monkeypatch.setenv('SSL_CERT_FILE', str(cert_path)) try: p = invoke(token_file, f'https://127.0.0.1:{server.server_port}') assert p.returncode == 1 and not p.stdout and not seen assert token_file.read_text().strip() not in p.stderr finally: server.shutdown(); server.server_close(); thread.join()