net-kingdom/tests/test_mfa_state_diagnostic.py

234 lines
12 KiB
Python
Raw Normal View History

"""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()