mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
a531afd02a
Expose pairing readiness and coordinate adapter hand-offs with Android Auto. Show app pairings separately, support individual removal, and improve pairing controls on both comma screens. Add Galaxy companion endpoints and web asset manifests, preserve the plain camera look, and fix headless speed-limit shapes.
390 lines
20 KiB
Python
390 lines
20 KiB
Python
"""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()
|