"""Actual loopback HTTP auth with disposable credentials and injected time.""" import http.client from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError import json from pathlib import Path import socket import tempfile import threading import unittest from unittest import mock from openpilot.starpilot.galaxy.access import GalaxyAccessOwner from openpilot.starpilot.galaxy.server import _LocalHTTPServer, allowed_authority, direct_local_connection, make_server class AuthRuntimeTest(unittest.TestCase): def test_socket_destination_authority_rejects_dns_rebinding_and_mismatch(self): port = 8082 self.assertTrue(allowed_authority('127.0.0.1:8082', '127.0.0.1', port)) self.assertTrue(allowed_authority('localhost:8082', '127.0.0.1', port)) self.assertTrue(allowed_authority('192.168.4.21:8082', '192.168.4.21', port)) self.assertTrue(allowed_authority('100.64.1.5:8082', '100.64.1.5', port)) self.assertFalse(allowed_authority('device.example:8082', '192.168.4.21', port)) self.assertFalse(allowed_authority('192.168.4.22:8082', '192.168.4.21', port)) self.assertFalse(allowed_authority('localhost:8082', '192.168.4.21', port)) self.assertFalse(allowed_authority('0.0.0.0:8082', '0.0.0.0', port)) self.assertFalse(allowed_authority('192.168.4.21:9999', '192.168.4.21', port)) def test_wildcard_bound_server_retains_password_and_origin_guards(self): self.stop() self.server = make_server(port=0, host='0.0.0.0', owner=self.owner, monitor=self.monitor, clock=lambda: self.now) self.assertEqual(self.server.server_address[0], '0.0.0.0') self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True) self.worker.start() self.assertEqual(self.request('/api/system/monitor')[0], 503) self.assertTrue(self.owner.configure('password123', lambda: True)) self.assertEqual(self.request('/api/system/monitor')[0], 401) self.assertEqual(self.request('/api/auth/session', headers={'Host': 'device.example:8082'})[0], 403) status, _, cookie = self.login() self.assertEqual(status, 200) self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': cookie.split(';', 1)[0]})[0], 200) self.assertEqual(self.post('/api/auth/logout', {}, headers={'Origin': 'http://other.example', 'Cookie': cookie.split(';', 1)[0]})[0], 403) def setUp(self): self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) self.owner = GalaxyAccessOwner(Path(self.directory.name) / 'access') self.now = 100.0 self.samples = 0 class Monitor: def sample(inner): self.samples += 1 return {'mode': 'local-runtime', 'schemaVersion': 1} self.monitor = Monitor() self.start() def start(self): self.server = make_server(port=0, owner=self.owner, monitor=self.monitor, clock=lambda: self.now) self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True) self.worker.start() self.addCleanup(self.stop) def stop(self): if self.server is not None: self.server.shutdown() self.worker.join(timeout=2) self.server.server_close() self.server = None def request(self, path, *, method='GET', body=None, headers=None, local=False): connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=2) try: request_headers = {} if local else {'Forwarded': 'for=203.0.113.8'} request_headers.update(headers or {}) connection.request(method, path, body=body, headers=request_headers) response = connection.getresponse() return response.status, response.read(), dict(response.getheaders()) finally: connection.close() def post(self, path, payload, *, headers=None, local=False): merged = {'Content-Type': 'application/json', 'Origin': f'http://127.0.0.1:{self.server.server_port}'} merged.update(headers or {}) return self.request(path, method='POST', body=json.dumps(payload), headers=merged, local=local) def login(self, password='password123'): status, body, headers = self.post('/api/auth/login', {'password': password}) return status, body, headers.get('Set-Cookie', '') def test_static_assets_revalidate_and_compress_without_caching_private_api(self): import gzip path = '/vendor/vue/vue.esm-browser.js' status, plain, headers = self.request(path) self.assertEqual(status, 200) self.assertEqual(headers['Cache-Control'], 'private, max-age=0, must-revalidate') status, body, unchanged = self.request(path, headers={'If-None-Match': headers['ETag']}) self.assertEqual((status, body), (304, b'')) self.assertEqual(unchanged['ETag'], headers['ETag']) status, packed, compressed = self.request(path, headers={'Accept-Encoding': 'gzip'}) self.assertEqual(status, 200) self.assertEqual(gzip.decompress(packed), plain) self.assertLess(len(packed), len(plain) / 2) self.assertNotEqual(compressed['ETag'], headers['ETag']) self.assertEqual(compressed['Vary'], 'Accept-Encoding') self.assertEqual(self.request('/api/auth/session')[2]['Cache-Control'], 'no-store') self.assertEqual(self.request(path, headers={'Accept-Encoding': 'gzip;q=0'})[1], plain) def test_passwordless_boundary_requires_local_peer_destination_and_no_proxy_headers(self): for peer, destination in (('127.0.0.1', '127.0.0.1'), ('192.168.3.2', '192.168.50.20'), ('10.1.2.3', '10.1.2.4'), ('172.16.0.2', '172.31.0.3'), ('169.254.1.2', '169.254.1.3')): with self.subTest(peer=peer, destination=destination): self.assertTrue(direct_local_connection(peer, destination, {})) for peer, destination in (('8.8.8.8', '192.168.50.20'), ('192.168.3.2', '8.8.8.8'), ('100.64.1.2', '100.64.1.3'), ('172.32.0.2', '172.16.0.3'), ('invalid', '127.0.0.1'), ('::1', '127.0.0.1'), ('127.0.0.1', '0.0.0.0')): with self.subTest(peer=peer, destination=destination): self.assertFalse(direct_local_connection(peer, destination, {})) for header in ('Forwarded', 'Via', 'X-Forwarded-For', 'X-Forwarded-Host', 'X-Real-IP', 'X-Client-IP', 'X-Original-Forwarded-For', 'CF-Connecting-IP', 'True-Client-IP'): with self.subTest(header=header): self.assertFalse(direct_local_connection('127.0.0.1', '127.0.0.1', {header: ''})) def test_local_bootstrap_never_requires_or_reads_password(self): with mock.patch.object(self.owner, 'status', side_effect=AssertionError('Local access read credentials')), \ mock.patch.object(self.owner, 'current_generation', side_effect=AssertionError('Local access read credentials')): status, body, headers = self.request('/api/auth/session', local=True) self.assertEqual(status, 200) self.assertEqual(json.loads(body), {'authenticated': True, 'state': 'configured', 'localAccess': True}) cookie = headers['Set-Cookie'].split(';', 1)[0] self.assertIn('HttpOnly', headers['Set-Cookie']) self.assertIn('SameSite=Strict', headers['Set-Cookie']) self.assertEqual(self.request('/api/system/monitor', local=True, headers={'Cookie': cookie})[0], 200) reused = self.request('/api/auth/session', local=True, headers={'Cookie': cookie})[2]['Set-Cookie'] self.assertEqual(reused.split(';', 1)[0], cookie) self.assertEqual(self.post('/api/auth/logout', {}, local=True, headers={'Cookie': cookie})[0], 200) self.assertFalse(self.owner.root.exists()) self.assertEqual(self.request('/api/system/monitor', local=True, headers={'Cookie': cookie})[0], 401) def test_local_sessions_expire_restart_and_cannot_authenticate_forwarded_access(self): cookie = self.request('/api/auth/session', local=True)[2]['Set-Cookie'].split(';', 1)[0] self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': cookie})[0], 503) self.assertTrue(self.owner.configure('password123', lambda: True)) self.assertEqual(self.request('/api/system/monitor', local=True, headers={'Cookie': cookie})[0], 200) self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': cookie})[0], 401) self.assertEqual(json.loads(self.request('/api/auth/session', headers={'Cookie': cookie})[1]), {'authenticated': False, 'state': 'configured', 'localAccess': False}) self.now += 1800 self.assertEqual(self.request('/api/system/monitor', local=True, headers={'Cookie': cookie})[0], 401) refreshed = self.request('/api/auth/session', local=True, headers={'Cookie': cookie})[2]['Set-Cookie'].split(';', 1)[0] self.assertNotEqual(refreshed, cookie) self.assertTrue(self.owner.remove(lambda: True)) self.assertEqual(self.request('/api/system/monitor', local=True, headers={'Cookie': refreshed})[0], 200) self.stop() self.start() self.assertEqual(self.request('/api/system/monitor', local=True, headers={'Cookie': refreshed})[0], 401) def test_local_bootstrap_does_not_reset_remote_login_throttle(self): self.assertTrue(self.owner.configure('password123', lambda: True)) for _ in range(5): self.assertEqual(self.login('wrong-password')[0], 401) cookie = self.request('/api/auth/session', local=True)[2]['Set-Cookie'].split(';', 1)[0] self.assertEqual(self.request('/api/system/monitor', local=True, headers={'Cookie': cookie})[0], 200) self.assertEqual(self.login()[0], 429) def test_local_session_keeps_host_origin_and_source_guards_for_driving_preferences(self): from openpilot.common.params import Params from openpilot.starpilot.galaxy.settings import AuthorityContext, SettingsGateway from openpilot.starpilot.ui.appearance_preferences import read_visibility class Context: value = AuthorityContext(True, None, None) def sample(inner): return inner.value context = Context() params = Params(str(Path(self.directory.name) / 'params')) gateway = SettingsGateway(params, context) self.stop() self.server = make_server(port=0, owner=self.owner, monitor=self.monitor, settings=gateway, clock=lambda: self.now) self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True) self.worker.start() cookie = self.request('/api/auth/session', local=True)[2]['Set-Cookie'].split(';', 1)[0] headers = {'Cookie': cookie} self.assertEqual(self.request('/api/auth/session', local=True, headers={'Host': 'device.example'})[0], 403) for change in ('saved', 'parked', 'none'): with self.subTest(change=change): Path(params.get_param_path('SignalMetrics')).unlink(missing_ok=True) context.value = AuthorityContext(True, None, None) page = json.loads(self.request('/api/settings/pages/appearance', local=True, headers=headers)[1]) index = next(i for i, row in enumerate(page['rows']) if row['label'] == 'C4 amber signal border') payload = {'view': page['view'], 'row': index, 'value': 'On'} self.assertEqual(self.post('/api/settings/preview', payload, local=True, headers=headers | {'Origin': 'http://other.example'})[0], 403) status, body, _ = self.post('/api/settings/preview', payload, local=True, headers=headers) self.assertEqual(status, 200) intent = json.loads(body)['intent'] if change == 'saved': Path(params.get_param_path('SignalMetrics')).write_bytes(b'0') elif change == 'parked': context.value = AuthorityContext(False, None, None) status, _, _ = self.post('/api/settings/confirm', {'intent': intent, 'confirmed': True}, local=True, headers=headers) self.assertEqual(status, 409 if change == 'saved' else 200) self.assertIs(read_visibility(params, 'SignalMetrics').value, change != 'saved') def test_setup_login_monitor_logout_and_no_secret_exposure(self): self.assertEqual(json.loads(self.request('/api/auth/session')[1])['state'], 'setup_required') self.assertEqual(self.request('/api/system/monitor')[0], 503) self.assertEqual(self.samples, 0) self.assertTrue(self.owner.configure('password123', lambda: True)) self.assertEqual(self.request('/api/system/monitor')[0], 401) self.assertEqual(self.samples, 0) self.assertEqual(self.login('bad-password')[0], 401) status, body, cookie = self.login() self.assertEqual(status, 200) self.assertEqual(json.loads(body), {'authenticated': True}) self.assertIn('HttpOnly', cookie) self.assertIn('SameSite=Strict', cookie) self.assertIn('Max-Age=1800', cookie) self.assertNotIn('password123', cookie) token = cookie.split(';', 1)[0] self.assertTrue(json.loads(self.request('/api/auth/session', headers={'Cookie': token})[1])['authenticated']) self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': token})[0], 200) self.assertEqual(self.samples, 1) logout_status, _, logout_headers = self.post('/api/auth/logout', {}, headers={'Cookie': token}) self.assertEqual(logout_status, 200) self.assertIn('Max-Age=0', logout_headers['Set-Cookie']) self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': token})[0], 401) self.assertEqual(self.samples, 1) def test_idle_connection_and_incomplete_body_do_not_block_other_requests(self): for pending in (b'', ('POST /api/auth/login HTTP/1.1\r\n' + f'Host: 127.0.0.1:{self.server.server_port}\r\n' + f'Origin: http://127.0.0.1:{self.server.server_port}\r\n' + 'Content-Type: application/json\r\nContent-Length: 100\r\n\r\n{').encode()): with self.subTest(pending=bool(pending)), socket.create_connection(('127.0.0.1', self.server.server_port), timeout=2) as held: if pending: held.sendall(pending) self.assertEqual(self.request('/api/auth/session')[0], 200) self.assertEqual(self.request('/api/system/monitor')[0], 503) self.assertEqual(self.samples, 0) def test_idle_connections_expire_and_capacity_recovers(self): # Observe accepted handlers so this exercises the socket limit, not backlog timing. condition = threading.Condition() accepted = 0 finished = 0 setup = self.server.RequestHandlerClass.setup finish = self.server.RequestHandlerClass.finish def observed_setup(handler): nonlocal accepted setup(handler) with condition: accepted += 1 condition.notify_all() def observed_finish(handler): nonlocal finished try: finish(handler) finally: with condition: finished += 1 condition.notify_all() held = [] with mock.patch.object(self.server.RequestHandlerClass, 'setup', observed_setup), \ mock.patch.object(self.server.RequestHandlerClass, 'finish', observed_finish), \ mock.patch.object(self.server, 'loopback', return_value=False): try: for _ in range(self.server.MAX_CONNECTIONS): held.append(socket.create_connection(('127.0.0.1', self.server.server_port), timeout=2)) with condition: self.assertTrue(condition.wait_for(lambda: accepted == self.server.MAX_CONNECTIONS, timeout=2)) admission_started = threading.Event() process_request = self.server.process_request def waiting_request(request, address): admission_started.set() process_request(request, address) with mock.patch.object(self.server, 'process_request', side_effect=waiting_request), ThreadPoolExecutor(max_workers=1) as executor: opening = executor.submit(self.request, '/js/boot.js') self.assertTrue(admission_started.wait(timeout=2)) with self.assertRaises(FutureTimeoutError): opening.result(timeout=0.05) held.pop().close() status, body, _ = opening.result(timeout=2) self.assertEqual(status, 200) self.assertIn(b'import("./app.js")', body) finally: for connection in held: connection.close() with condition: self.assertTrue(condition.wait_for(lambda: finished >= self.server.MAX_CONNECTIONS, timeout=2)) self.assertEqual(self.request('/api/auth/session')[0], 200) with mock.patch.object(_LocalHTTPServer, 'REQUEST_TIMEOUT', 0.05): with socket.create_connection(('127.0.0.1', self.server.server_port), timeout=2) as idle: self.assertEqual(idle.recv(1), b'') self.assertEqual(self.request('/api/auth/session')[0], 200) def test_concurrent_login_attempts_share_the_failure_budget(self): self.assertTrue(self.owner.configure('password123', lambda: True)) with mock.patch.object(self.owner, 'authenticate_generation', wraps=self.owner.authenticate_generation) as verifier: with ThreadPoolExecutor(max_workers=6) as pool: statuses = list(pool.map(lambda _: self.login('wrong-password')[0], range(6))) self.assertEqual(sorted(statuses), [401] * 5 + [429]) self.assertEqual(verifier.call_count, 5) self.assertEqual(self.login()[0], 429) self.now += 30 self.assertEqual(self.login()[0], 200) def test_parallel_monitor_requests_serialize_shared_sample_history(self): self.assertTrue(self.owner.configure('password123', lambda: True)) cookie = self.login()[2].split(';', 1)[0] active = 0 maximum = 0 count_lock = threading.Lock() def sample(): nonlocal active, maximum with count_lock: active += 1 maximum = max(maximum, active) threading.Event().wait(0.02) with count_lock: active -= 1 return {'mode': 'local-runtime', 'schemaVersion': 1} with mock.patch.object(self.monitor, 'sample', side_effect=sample): with ThreadPoolExecutor(max_workers=4) as pool: statuses = list(pool.map(lambda _: self.request('/api/system/monitor', headers={'Cookie': cookie})[0], range(4))) self.assertEqual(statuses, [200] * 4) self.assertEqual(maximum, 1) def test_expiry_restart_and_credential_change_revoke_session(self): self.assertTrue(self.owner.configure('password123', lambda: True)) cookie = self.login()[2].split(';', 1)[0] self.now += 1800 self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': cookie})[0], 401) cookie = self.login()[2].split(';', 1)[0] self.assertTrue(self.owner.remove(lambda: True)) self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': cookie})[0], 503) self.assertTrue(self.owner.configure('newpassword123', lambda: True)) self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': cookie})[0], 401) fresh = self.login('newpassword123')[2].split(';', 1)[0] self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': fresh})[0], 200) self.stop() self.start() self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': fresh})[0], 401) def test_corrupt_credential_record_invalidates_session(self): self.assertTrue(self.owner.configure('password123', lambda: True)) cookie = self.login()[2].split(';', 1)[0] record = self.owner.root / self.owner.FILE record.write_text('{}') self.assertEqual(json.loads(self.request('/api/auth/session', headers={'Cookie': cookie})[1])['state'], 'unavailable') self.assertEqual(self.request('/api/system/monitor', headers={'Cookie': cookie})[0], 503) self.assertEqual(self.login()[0], 503) self.assertEqual(self.samples, 0) def test_host_origin_json_size_methods_and_login_throttle(self): self.assertTrue(self.owner.configure('password123', lambda: True)) self.assertEqual(self.request('/api/system/monitor', headers={'Host': 'other.example'})[0], 403) self.assertEqual(self.post('/api/auth/login', {'password': 'password123'}, headers={'Origin': 'https://other.example'})[0], 403) self.assertEqual(self.request('/api/auth/login', method='POST', body='{}', headers={'Content-Type': 'application/json'})[0], 403) self.assertEqual(self.post('/api/auth/login', {'password': 'password123'}, headers={'Content-Type': 'text/plain'})[0], 415) self.assertEqual(self.post('/api/auth/login', {'password': 'x' * 5000})[0], 413) self.assertEqual(self.request('/api/auth/login', method='OPTIONS')[0], 403) self.assertEqual(self.post('/api/vehicle', {})[0], 405) self.assertEqual(self.request('/api/vehicle')[0], 401) self.assertEqual(self.request('/%2e%2e/server.py')[0], 404) for _ in range(5): self.assertEqual(self.login('wrong-password')[0], 401) self.assertEqual(self.login()[0], 429) self.now += 30 self.assertEqual(self.login()[0], 200) self.assertEqual(self.samples, 0) if __name__ == '__main__': unittest.main()