mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 13:01:41 +02:00
F-Droid repositories can now be added with HTTP basic auth, either as https://user:pass@host/fdroid/repo (as F-Droid's client accepts) or with separate username/password fields (store form, server API, and the CLI's --username/--password-stdin). The login is stripped from the saved URL and kept in a new shared module, apk_sources/_credentials.py, which writes source-credentials.json in the data folder with mode 0600. A credential is only ever added per request to its exact origin (scheme, host, port): redirects elsewhere get nothing and have Authorization removed. HTTPS only. 401 (or 403 with a login) on add gives a "check the username and password" error and saves nothing; signature, pin, TOFU, hash and rollback checks are unchanged. Removing a repository deletes its login. GitHub gets a user-settable token (FRAME_GITHUB_TOKEN still wins), sent as Bearer only to api.github.com. If GitHub answers 401, the request is retried once without it and the rejection is reported in source settings. The source list shows only whether a token is set, never its value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
19 KiB
Python
392 lines
19 KiB
Python
"""Repositories and sources behind a login: F-Droid basic auth and GitHub tokens.
|
|
|
|
A fake HTTPS layer stands in for the network (urllib's real redirect, error and
|
|
request processing still run), so these tests see every request and its headers.
|
|
"""
|
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
|
import base64
|
|
import contextlib
|
|
import http.client
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import urllib.request
|
|
import urllib.response
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
|
from apk_sources import SourceAuthError, SourceError, _credentials, _images, _web, fdroid, github, search
|
|
import server
|
|
|
|
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
|
|
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
|
|
REPO = 'https://repo.example/fdroid/repo/'
|
|
USER, PASSWORD = 'alice', 's3cr@t-pässword'
|
|
TOKEN = 'ghp_frameControlTestToken0123456789'
|
|
SECRETS = (PASSWORD, TOKEN, base64.b64encode((USER + ':' + PASSWORD).encode()).decode())
|
|
REAL_BUILD_OPENER = urllib.request.build_opener
|
|
|
|
|
|
def basic(user, password):
|
|
return 'Basic ' + base64.b64encode((user + ':' + password).encode()).decode()
|
|
|
|
|
|
class FakeHTTPS(urllib.request.HTTPSHandler):
|
|
"""Answers https requests from route(url, authorization) -> (status, headers, body)."""
|
|
|
|
def __init__(self, route):
|
|
super().__init__()
|
|
self.route, self.seen = route, []
|
|
|
|
def https_open(self, req):
|
|
auth = req.get_header('Authorization')
|
|
self.seen.append((req.full_url, auth))
|
|
status, headers, body = self.route(req.full_url, auth)
|
|
message = http.client.HTTPMessage()
|
|
for key, value in headers.items():
|
|
message[key] = value
|
|
response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, status)
|
|
response.msg = 'fake'
|
|
return response
|
|
|
|
|
|
class Base(unittest.TestCase):
|
|
def setUp(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self.root = Path(tmp.name)
|
|
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
|
|
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
|
|
mock = patch.object(fdroid.frame_host, name, value)
|
|
mock.start()
|
|
self.addCleanup(mock.stop)
|
|
self.web = FakeHTTPS(self.route)
|
|
mock = patch('urllib.request.build_opener', lambda *handlers: REAL_BUILD_OPENER(*handlers, self.web))
|
|
mock.start()
|
|
self.addCleanup(mock.stop)
|
|
mock = patch.dict(os.environ, {github.ENV: ''})
|
|
mock.start()
|
|
self.addCleanup(mock.stop)
|
|
for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing, github._rejected):
|
|
state.clear()
|
|
self.addCleanup(state.clear)
|
|
self.stderr = io.StringIO()
|
|
mock = patch('sys.stderr', self.stderr)
|
|
mock.start()
|
|
self.addCleanup(mock.stop)
|
|
|
|
def route(self, url, auth):
|
|
raise AssertionError('unexpected request ' + url)
|
|
|
|
def assertNoSecrets(self, *texts):
|
|
for text in texts:
|
|
for secret in SECRETS + (USER + ':',):
|
|
self.assertNotIn(secret, text)
|
|
|
|
def disk(self):
|
|
"""Everything saved under the data and cache folders except the credentials file."""
|
|
out = []
|
|
for path in self.root.rglob('*'):
|
|
if path.is_file() and path.name != 'source-credentials.json':
|
|
out.append(path.read_bytes().decode('utf-8', 'replace'))
|
|
return '\n'.join(out)
|
|
|
|
def credentials_file(self):
|
|
return self.root / 'data' / 'source-credentials.json'
|
|
|
|
|
|
class Repository(Base):
|
|
"""A repository at REPO that wants USER and PASSWORD."""
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.login = basic(USER, PASSWORD)
|
|
self.redirect = None # file name -> absolute URL it redirects to
|
|
self.forbidden = 401
|
|
|
|
def serve(self, name):
|
|
return (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
|
|
|
|
def route(self, url, auth):
|
|
for mirror in ('https://mirror.example/fdroid/repo/', 'https://repo.example/elsewhere/'):
|
|
if url.startswith(mirror): # serves anyone; a login must never get here unless same-origin
|
|
return 200, {}, self.serve(url[len(mirror):])
|
|
self.assertTrue(url.startswith(REPO), url)
|
|
if auth != self.login:
|
|
return self.forbidden, {'WWW-Authenticate': 'Basic realm="repo"'}, b'no'
|
|
name = url[len(REPO):]
|
|
if self.redirect and name in self.redirect:
|
|
return 302, {'Location': self.redirect[name]}, b''
|
|
return 200, {}, self.serve(name)
|
|
|
|
def add(self, **kw):
|
|
return fdroid.add_repo('https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo', PIN, **kw)
|
|
|
|
|
|
class RepositoryLogin(Repository):
|
|
def test_add_search_download_with_login_in_the_url(self):
|
|
source = self.add()
|
|
self.assertEqual(source['url'], REPO)
|
|
self.assertEqual(source['fingerprint'], PIN)
|
|
saved = fdroid.user_repos()[0]
|
|
self.assertEqual(saved['url'], REPO)
|
|
self.assertNotIn('login', saved)
|
|
self.assertEqual(len(fdroid.search(source, 'example offline')), 1)
|
|
downloaded = fdroid.download(source, 'org.example.app', 1)
|
|
self.assertTrue(downloaded['verified'])
|
|
self.assertTrue(self.web.seen)
|
|
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
|
|
self.assertTrue(all('@' not in url for url, auth in self.web.seen)) # never in the URL
|
|
self.assertTrue(next(s for s in fdroid.sources() if s['id'] == source['id'])['login'])
|
|
self.assertNoSecrets(json.dumps(source), json.dumps(fdroid.sources()), self.disk(), self.stderr.getvalue())
|
|
# The settings file holds no part of the login; the credentials file holds it, privately.
|
|
settings = (self.root / 'data' / 'apk-repos.json').read_text()
|
|
self.assertNotIn(USER, settings)
|
|
self.assertTrue(self.credentials_file().is_file())
|
|
if os.name != 'nt':
|
|
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
|
|
|
def test_login_given_apart_and_saved_login_reused_on_refresh(self):
|
|
source = fdroid.add_repo(REPO, PIN, username=USER, password=PASSWORD)
|
|
self.assertEqual(source['url'], REPO)
|
|
self.web.seen.clear()
|
|
fdroid._load(fdroid.user_repos()[0], force=True) # as a background refresh would
|
|
self.assertTrue(self.web.seen)
|
|
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
|
|
fdroid.add_repo(REPO) # re-adding without a login keeps the saved one
|
|
self.assertTrue(_credentials.has(source['id']))
|
|
|
|
def test_wrong_or_missing_login_saves_nothing(self):
|
|
for kw, message in [({'username': USER, 'password': 'wrong'}, 'check the username and password'),
|
|
({}, 'needs a username and password')]:
|
|
with self.subTest(kw=kw):
|
|
with self.assertRaisesRegex(SourceAuthError, message) as caught:
|
|
fdroid.add_repo(REPO, PIN, **kw)
|
|
self.assertNoSecrets(str(caught.exception), repr(caught.exception))
|
|
self.assertNotIn('wrong', str(caught.exception))
|
|
self.assertEqual(fdroid.user_repos(), [])
|
|
self.assertFalse(self.credentials_file().exists())
|
|
self.assertFalse((self.root / 'data' / 'apk-repo-state.json').exists())
|
|
self.assertEqual(_web._limited, {}) # an auth failure isn't a rate limit
|
|
|
|
def test_403_with_a_login_is_a_login_error_not_a_rate_limit(self):
|
|
self.forbidden = 403
|
|
with self.assertRaisesRegex(SourceAuthError, 'check the username and password'):
|
|
fdroid.add_repo(REPO, PIN, username=USER, password='wrong')
|
|
self.assertEqual(_web._limited, {})
|
|
self.assertEqual(fdroid.user_repos(), [])
|
|
|
|
def test_signature_checks_still_apply(self):
|
|
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
|
|
fdroid.add_repo(REPO, '0' * 64, username=USER, password=PASSWORD)
|
|
self.assertEqual(fdroid.user_repos(), [])
|
|
self.assertFalse(self.credentials_file().exists())
|
|
|
|
def test_cross_origin_redirect_never_gets_the_login(self):
|
|
self.redirect = {'entry.jar': 'https://mirror.example/fdroid/repo/entry.jar',
|
|
'index-v2.json': 'https://repo.example/elsewhere/index-v2.json'}
|
|
source = self.add()
|
|
self.assertEqual(source['fingerprint'], PIN) # the mirror's copy still had to verify
|
|
mirror = [auth for url, auth in self.web.seen if url.startswith('https://mirror.example/')]
|
|
same_origin = [auth for url, auth in self.web.seen if url.startswith('https://repo.example/elsewhere/')]
|
|
self.assertEqual(mirror, [None])
|
|
self.assertEqual(same_origin, [self.login]) # same scheme, host and port: still that repository's
|
|
|
|
def test_other_ports_and_http_never_get_the_login(self):
|
|
login = _credentials.basic(REPO, USER, PASSWORD)
|
|
for url in ('https://repo.example:8443/fdroid/repo/x', 'http://repo.example/fdroid/repo/x',
|
|
'https://other.example/fdroid/repo/x', 'https://repo.example.evil/x'):
|
|
self.assertFalse(login.matches(url), url)
|
|
self.assertTrue(login.matches('https://REPO.example:443/anything'))
|
|
self.assertNoSecrets(repr(login))
|
|
handler = _credentials.Handler(login)
|
|
req = urllib.request.Request('https://other.example/x', headers={'Authorization': 'Basic stale'})
|
|
self.assertIsNone(handler.https_request(req).get_header('Authorization'))
|
|
|
|
def test_http_and_bad_logins_are_refused_before_any_request(self):
|
|
for url, kw in [('http://alice:pw@repo.example/fdroid/repo', {}),
|
|
('http://repo.example/fdroid/repo', {'username': USER, 'password': PASSWORD}),
|
|
(REPO, {'username': 'a:b', 'password': PASSWORD}),
|
|
(REPO, {'username': USER, 'password': 'x\r\nX-Evil: 1'}),
|
|
(REPO, {'password': PASSWORD}),
|
|
('https://alice:pw@repo.example/fdroid/repo', {'username': 'bob'})]:
|
|
with self.subTest(url=url, kw=kw), self.assertRaises(SourceError) as caught:
|
|
fdroid.add_repo(url, PIN, **kw)
|
|
self.assertNotIn('pw', str(caught.exception).replace('password', ''))
|
|
self.assertNoSecrets(str(caught.exception))
|
|
self.assertEqual(self.web.seen, [])
|
|
|
|
def test_remove_deletes_the_login(self):
|
|
source = self.add()
|
|
other = _credentials.bearer('https://api.github.com/', TOKEN)
|
|
_credentials.put('github', other)
|
|
fdroid.remove_repo(source['id'])
|
|
self.assertFalse(_credentials.has(source['id']))
|
|
self.assertNotIn(PASSWORD, self.credentials_file().read_text())
|
|
_credentials.remove('github')
|
|
self.assertFalse(self.credentials_file().exists()) # nothing left: the file goes
|
|
|
|
def test_cli_reads_the_password_from_stdin(self):
|
|
out = io.StringIO()
|
|
argv = ['fdroid.py', 'add', REPO, '--fingerprint', PIN, '--username', USER, '--password-stdin']
|
|
with patch.object(sys, 'argv', argv), patch.object(sys, 'stdin', io.StringIO(PASSWORD + '\n')), \
|
|
contextlib.redirect_stdout(out):
|
|
fdroid.main()
|
|
self.assertEqual(json.loads(out.getvalue())['url'], REPO)
|
|
self.assertNoSecrets(out.getvalue())
|
|
self.assertTrue(_credentials.has(fdroid.user_repos()[0]['id']))
|
|
|
|
def test_artwork_from_the_repository_gets_the_login_and_only_there(self):
|
|
source = self.add()
|
|
self.assertEqual(_credentials.for_url(REPO + 'icons/a.png').header(), self.login)
|
|
self.assertTrue(_credentials.has(source['id']))
|
|
self.assertIsNone(_credentials.for_url('https://repo.example/other/a.png')) # outside the repository
|
|
self.assertIsNone(_credentials.for_url('https://cdn.example/fdroid/repo/a.png'))
|
|
|
|
|
|
class GitHubToken(Base):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.accept = {TOKEN}
|
|
self.releases = json.dumps([]).encode()
|
|
|
|
def route(self, url, auth):
|
|
if url.startswith('https://api.github.com/'):
|
|
if auth is not None and auth not in {'Bearer ' + t for t in self.accept}:
|
|
return 401, {}, b'{"message": "Bad credentials"}'
|
|
if url.endswith('/redirected'):
|
|
return 302, {'Location': 'https://github.com/landed'}, b''
|
|
return 200, {}, self.releases
|
|
if url == 'https://github.com/landed':
|
|
return 200, {}, b'{"ok": true}'
|
|
raise AssertionError('unexpected request ' + url)
|
|
|
|
def test_saved_token_goes_only_to_the_api(self):
|
|
github.set_token(TOKEN)
|
|
self.assertEqual(github._api('/repos/a/b/releases'), [])
|
|
self.assertEqual(self.web.seen, [('https://api.github.com/repos/a/b/releases', 'Bearer ' + TOKEN)])
|
|
auth = github._credential(github._token()[0])
|
|
self.assertEqual(_web.read('https://api.github.com/redirected', ('api.github.com', 'github.com'), auth=auth),
|
|
b'{"ok": true}')
|
|
self.assertEqual(self.web.seen[-2:], [('https://api.github.com/redirected', 'Bearer ' + TOKEN),
|
|
('https://github.com/landed', None)]) # redirect: no token
|
|
status = github.sources()[0]['token']
|
|
self.assertEqual(status, {'set': True, 'from': 'saved', 'rejected': False})
|
|
self.assertNoSecrets(json.dumps(github.sources()), self.disk(), self.stderr.getvalue())
|
|
if os.name != 'nt':
|
|
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
|
|
|
def test_rejected_token_retries_once_without_it(self):
|
|
github.set_token(TOKEN)
|
|
self.accept = set()
|
|
self.assertEqual(github._api('/repos/a/b/releases'), [])
|
|
self.assertEqual([auth for _, auth in self.web.seen], ['Bearer ' + TOKEN, None])
|
|
self.assertTrue(github.token_status()['rejected'])
|
|
self.assertIn('rejected', self.stderr.getvalue())
|
|
self.assertNoSecrets(self.stderr.getvalue())
|
|
self.web.seen.clear()
|
|
github._api('/repos/a/b/other')
|
|
self.assertEqual([auth for _, auth in self.web.seen], [None]) # not tried again this run
|
|
github.set_token(TOKEN) # saving it again gives it a fresh chance
|
|
self.assertFalse(github.token_status()['rejected'])
|
|
|
|
def test_environment_variable_wins(self):
|
|
github.set_token(TOKEN)
|
|
env = 'ghp_fromTheEnvironment'
|
|
self.accept = {env}
|
|
with patch.dict(os.environ, {github.ENV: env}):
|
|
github._api('/repos/a/b/releases')
|
|
self.assertEqual(self.web.seen[-1][1], 'Bearer ' + env)
|
|
self.assertEqual(github.token_status(), {'set': True, 'from': 'env', 'rejected': False})
|
|
self.assertEqual(github.token_status()['from'], 'saved')
|
|
|
|
def test_clear_removes_it_from_disk(self):
|
|
github.set_token(TOKEN)
|
|
github.clear_token()
|
|
self.assertFalse(self.credentials_file().exists())
|
|
self.assertEqual(github.token_status(), {'set': False, 'from': None, 'rejected': False})
|
|
github._api('/repos/a/b/releases')
|
|
self.assertEqual(self.web.seen[-1][1], None)
|
|
|
|
def test_bad_tokens_are_refused(self):
|
|
for token in ('', ' ', 'two words', 'x\nAuthorization: y'):
|
|
with self.subTest(token=token), self.assertRaises(SourceError):
|
|
github.set_token(token)
|
|
self.assertFalse(self.credentials_file().exists())
|
|
|
|
|
|
class Endpoints(Repository):
|
|
"""The server API: logins in, never out, in responses or the server log."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
mock = patch.object(search, 'modules', return_value=([fdroid, github], []))
|
|
mock.start()
|
|
self.addCleanup(mock.stop)
|
|
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
self.addCleanup(self.httpd.server_close)
|
|
self.addCleanup(self.httpd.shutdown)
|
|
|
|
def request(self, method, path, body=None):
|
|
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
|
|
c.request(method, path, json.dumps(body) if body is not None else None,
|
|
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
|
|
r = c.getresponse()
|
|
raw = r.read().decode()
|
|
c.close()
|
|
self.replies.append(raw)
|
|
return r.status, json.loads(raw)
|
|
|
|
def job(self, reply):
|
|
for _ in range(500):
|
|
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
|
|
if job['done']:
|
|
return job
|
|
time.sleep(.01)
|
|
self.fail('job did not finish')
|
|
|
|
def test_add_with_login_and_token_settings(self):
|
|
self.replies = []
|
|
bad = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
|
|
'username': USER, 'password': 'wrong'})[1])
|
|
self.assertIn('check the username and password', bad['error'])
|
|
self.assertEqual(fdroid.user_repos(), [])
|
|
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
|
|
'username': USER, 'password': PASSWORD})
|
|
self.assertEqual(code, 200)
|
|
job = self.job(reply)
|
|
self.assertIsNone(job['error'])
|
|
listed = self.request('GET', '/api/sources')[1]['sources']
|
|
repo = next(s for s in listed if s['id'] == job['result']['source']['id'])
|
|
self.assertEqual((repo['url'], repo['login']), (REPO, True))
|
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'github',
|
|
'token': TOKEN})[0], 200)
|
|
github_row = next(s for s in self.request('GET', '/api/sources')[1]['sources'] if s['id'] == 'github')
|
|
self.assertEqual(github_row['token'], {'set': True, 'from': 'saved', 'rejected': False})
|
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'fdroid',
|
|
'token': TOKEN})[0], 400)
|
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'clear-token', 'source': 'github'})[0], 200)
|
|
self.assertFalse(_credentials.has('github'))
|
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': repo['id']})[0], 200)
|
|
self.assertFalse(self.credentials_file().exists())
|
|
self.assertNoSecrets(*self.replies, self.stderr.getvalue(), self.disk())
|
|
|
|
def test_login_in_the_url_is_accepted(self):
|
|
self.replies = []
|
|
url = 'https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo'
|
|
job = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': url, 'fingerprint': PIN})[1])
|
|
self.assertIsNone(job['error'])
|
|
self.assertEqual(fdroid.user_repos()[0]['url'], REPO)
|
|
self.assertNoSecrets(*self.replies, self.stderr.getvalue())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|