234 lines
12 KiB
Python
234 lines
12 KiB
Python
|
|
"""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()
|