feat: add saved SSH keys and terminal workspace tools
This commit is contained in:
+41
-3
@@ -852,6 +852,44 @@ class TestAppWithUnknownEncoding(OtherTestBase):
|
||||
|
||||
class TestAppWithSavedConnections(OtherTestBase):
|
||||
|
||||
def test_connection_form_exposes_separate_credential_controls(self):
|
||||
response = self.fetch('/')
|
||||
self.assertIn(b'name="save_credentials" value="1">', response.body)
|
||||
self.assertIn(b'name="save_passphrase" value="1">', response.body)
|
||||
self.assertIn(b'id="privatekey-hint"', response.body)
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_saved_key_can_connect_without_uploading_again(self):
|
||||
privatekey = read_file(make_tests_data_path('user_rsa_key'))
|
||||
body = dict(self.body, password='', privatekey=privatekey,
|
||||
save='1', save_credentials='1')
|
||||
response = yield self.async_post('/', body)
|
||||
data = json.loads(to_str(response.body))
|
||||
self.assert_status_none(data)
|
||||
self.assertTrue(data['profile']['has_privatekey'])
|
||||
self.assertNotIn('privatekey_enc', data['profile'])
|
||||
response = yield self.async_get('/connections')
|
||||
self.assertNotIn(b'privatekey_enc', response.body)
|
||||
self.assertNotIn(to_str(privatekey).encode('utf-8'), response.body)
|
||||
response = yield self.async_post('/', dict(self.body, password=''))
|
||||
self.assert_status_none(json.loads(to_str(response.body)))
|
||||
response = yield self.async_post(
|
||||
'/', dict(self.body, password='', username='bar')
|
||||
)
|
||||
self.assert_status_equal(
|
||||
'Authentication failed.', json.loads(to_str(response.body))
|
||||
)
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_save_without_credentials_clears_saved_password(self):
|
||||
body = dict(self.body, save='1', save_credentials='1')
|
||||
response = yield self.async_post('/', body)
|
||||
self.assert_status_none(json.loads(to_str(response.body)))
|
||||
response = yield self.async_post('/', dict(self.body, save='1'))
|
||||
data = json.loads(to_str(response.body))
|
||||
self.assert_status_none(data)
|
||||
self.assertFalse(data['profile']['has_password'])
|
||||
|
||||
def async_get(self, path):
|
||||
return self.get_http_client().fetch(
|
||||
self.get_url(path), headers=self.headers
|
||||
@@ -859,7 +897,7 @@ class TestAppWithSavedConnections(OtherTestBase):
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_saved_password_never_reaches_the_browser(self):
|
||||
body = dict(self.body, save='1')
|
||||
body = dict(self.body, save='1', save_credentials='1')
|
||||
response = yield self.async_post('/', body)
|
||||
data = json.loads(to_str(response.body))
|
||||
self.assert_status_none(data)
|
||||
@@ -880,7 +918,7 @@ class TestAppWithSavedConnections(OtherTestBase):
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_saved_password_is_reused_when_the_field_is_left_empty(self):
|
||||
body = dict(self.body, save='1')
|
||||
body = dict(self.body, save='1', save_credentials='1')
|
||||
response = yield self.async_post('/', body)
|
||||
self.assert_status_none(json.loads(to_str(response.body)))
|
||||
|
||||
@@ -891,7 +929,7 @@ class TestAppWithSavedConnections(OtherTestBase):
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_saved_password_is_not_sent_to_another_destination(self):
|
||||
body = dict(self.body, save='1')
|
||||
body = dict(self.body, save='1', save_credentials='1')
|
||||
response = yield self.async_post('/', body)
|
||||
self.assert_status_none(json.loads(to_str(response.body)))
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ class TestPrivateKey(unittest.TestCase):
|
||||
with self.assertRaises(InvalidValueError) as ctx:
|
||||
pk.get_pkey_obj()
|
||||
self.assertIn('wrong passphrase', str(ctx.exception))
|
||||
self.assertNotIn('wrongpass', str(ctx.exception))
|
||||
|
||||
pk = self.get_pk_obj(fname, password=password)
|
||||
self.assertIsInstance(pk.get_pkey_obj(), klass)
|
||||
@@ -318,12 +319,41 @@ class TestWsockHandler(unittest.TestCase):
|
||||
obj.close.assert_called_with(reason='Worker closed')
|
||||
|
||||
class TestIndexHandler(unittest.TestCase):
|
||||
def test_saved_encrypted_key_uses_supplied_or_saved_passphrase(self):
|
||||
privatekey = read_file(
|
||||
make_tests_data_path('test_rsa_password.key')
|
||||
)
|
||||
for supplied in ('', 'television'):
|
||||
obj = Mock(spec=IndexHandler)
|
||||
obj.get_hostname.return_value = '127.0.0.1'
|
||||
obj.get_port.return_value = 22
|
||||
obj.get_value.return_value = 'root'
|
||||
obj.get_privatekey.return_value = ('', '')
|
||||
obj.get_saved_privatekey.return_value = (
|
||||
privatekey, 'television' if not supplied else 'wrong'
|
||||
)
|
||||
obj.get_saved_password.return_value = ''
|
||||
obj.get_directory.return_value = ''
|
||||
obj.policy = paramiko.WarningPolicy()
|
||||
obj.ssh_client = Mock()
|
||||
values = {'passphrase': supplied, 'save_credentials': '1',
|
||||
'save_passphrase': '1'}
|
||||
obj.get_argument.side_effect = (
|
||||
lambda name, default=u'': values.get(name, default)
|
||||
)
|
||||
args = IndexHandler.get_args(obj)
|
||||
self.assertIsInstance(args[4], paramiko.RSAKey)
|
||||
self.assertEqual('television', obj.connection_info['passphrase'])
|
||||
self.assertEqual('privatekey', obj.connection_info['auth_type'])
|
||||
self.assertTrue(obj.connection_info['save_credentials'])
|
||||
|
||||
def test_get_args_keeps_password_for_saved_connection(self):
|
||||
obj = Mock(spec=IndexHandler)
|
||||
obj.get_hostname.return_value = '127.0.0.1'
|
||||
obj.get_port.return_value = 22
|
||||
obj.get_value.return_value = 'root'
|
||||
obj.get_privatekey.return_value = ('', '')
|
||||
obj.get_saved_privatekey.return_value = ('', '')
|
||||
obj.get_directory.return_value = ''
|
||||
obj.policy = paramiko.WarningPolicy()
|
||||
obj.ssh_client = Mock()
|
||||
@@ -352,6 +382,7 @@ class TestIndexHandler(unittest.TestCase):
|
||||
obj.get_port.return_value = 22
|
||||
obj.get_value.return_value = 'root'
|
||||
obj.get_privatekey.return_value = ('', '')
|
||||
obj.get_saved_privatekey.return_value = ('', '')
|
||||
obj.get_directory.return_value = '/var/www'
|
||||
obj.policy = paramiko.WarningPolicy()
|
||||
obj.ssh_client = Mock()
|
||||
@@ -362,6 +393,21 @@ class TestIndexHandler(unittest.TestCase):
|
||||
self.assertEqual('/var/www', args[6])
|
||||
self.assertEqual('/var/www', obj.connection_info['directory'])
|
||||
|
||||
def test_websocket_cannot_attach_twice_to_same_worker(self):
|
||||
obj = Mock(spec=WsockHandler)
|
||||
obj.current_user = 'admin'
|
||||
obj.get_client_addr.return_value = ('127.0.0.1', 1234)
|
||||
obj.get_client_key.return_value = 'user:test-double-attach'
|
||||
obj.get_value.return_value = 'existing'
|
||||
active_worker = Mock(handler=Mock())
|
||||
worker.clients['user:test-double-attach'] = {'existing': active_worker}
|
||||
try:
|
||||
WsockHandler.open(obj)
|
||||
obj.close.assert_called_once_with(reason='Websocket authentication failed.')
|
||||
active_worker.set_handler.assert_not_called()
|
||||
finally:
|
||||
worker.clients.pop('user:test-double-attach', None)
|
||||
|
||||
def test_get_directory(self):
|
||||
obj = Mock(spec=IndexHandler)
|
||||
values = {}
|
||||
|
||||
@@ -163,6 +163,119 @@ class TestConnectionStore(unittest.TestCase):
|
||||
})
|
||||
self.assertEqual('', profile['directory'])
|
||||
|
||||
def test_privatekey_is_encrypted_and_never_public(self):
|
||||
profile = self.store.upsert('admin', {
|
||||
'hostname': 'example.com', 'username': 'root',
|
||||
'privatekey': 'private-key-material',
|
||||
'passphrase': 'key-passphrase', 'save_passphrase': True,
|
||||
'auth_type': 'privatekey'
|
||||
})
|
||||
raw = self.read_raw()
|
||||
self.assertNotIn('private-key-material', raw)
|
||||
self.assertNotIn('key-passphrase', raw)
|
||||
self.assertTrue(profile['has_privatekey'])
|
||||
self.assertTrue(profile['has_passphrase'])
|
||||
for public in (profile, self.store.list('admin')[0]):
|
||||
for field in ('privatekey', 'privatekey_enc', 'passphrase',
|
||||
'passphrase_enc'):
|
||||
self.assertNotIn(field, public)
|
||||
self.assertEqual(
|
||||
('private-key-material', 'key-passphrase'),
|
||||
self.store.get_privatekey('admin', profile['id'])
|
||||
)
|
||||
self.assertEqual(
|
||||
('', ''), self.store.get_privatekey('other', profile['id'])
|
||||
)
|
||||
other_id = self.store.make_id('admin', 'evil.example', 22, 'root')
|
||||
self.assertEqual(('', ''),
|
||||
self.store.get_privatekey('admin', other_id))
|
||||
rotated = ConnectionStore(self.filename, 'other-secret')
|
||||
self.assertEqual(('', ''),
|
||||
rotated.get_privatekey('admin', profile['id']))
|
||||
|
||||
def test_credentials_can_be_replaced_and_cleared(self):
|
||||
connection = {
|
||||
'hostname': 'example.com', 'username': 'root',
|
||||
'password': 'password-secret', 'privatekey': 'old-key',
|
||||
'passphrase': 'key-secret', 'save_passphrase': True
|
||||
}
|
||||
profile = self.store.upsert('admin', connection)
|
||||
connection.update(privatekey='new-key', save_passphrase=False)
|
||||
self.store.upsert('admin', connection)
|
||||
self.assertEqual(('new-key', ''),
|
||||
self.store.get_privatekey('admin', profile['id']))
|
||||
connection['save_credentials'] = False
|
||||
public = self.store.upsert('admin', connection)
|
||||
self.assertFalse(public['has_password'])
|
||||
self.assertFalse(public['has_privatekey'])
|
||||
self.assertFalse(public['has_passphrase'])
|
||||
self.assertEqual('', self.store.get_password('admin', profile['id']))
|
||||
self.assertEqual(('', ''),
|
||||
self.store.get_privatekey('admin', profile['id']))
|
||||
|
||||
def test_privatekey_requires_encryption_secret(self):
|
||||
store = ConnectionStore(self.filename)
|
||||
with self.assertRaises(ValueError):
|
||||
store.upsert('admin', {
|
||||
'hostname': 'example.com', 'username': 'root',
|
||||
'privatekey': 'private-key-material'
|
||||
})
|
||||
self.assertFalse(os.path.exists(self.filename))
|
||||
|
||||
def test_metadata_update_keeps_credentials_and_owner_boundary(self):
|
||||
profile = self.store.upsert('admin', {
|
||||
'hostname': 'example.com', 'username': 'root',
|
||||
'password': 'secret', 'name': 'Production', 'group': 'Servers'
|
||||
})
|
||||
self.assertIsNone(self.store.update_metadata(
|
||||
'other', profile['id'], {'name': 'stolen'}
|
||||
))
|
||||
updated = self.store.update_metadata('admin', profile['id'], {
|
||||
'name': 'New name', 'group': 'Testing', 'notes': 'A note'
|
||||
})
|
||||
self.assertEqual('New name', updated['name'])
|
||||
self.assertEqual('secret', self.store.get_password('admin', profile['id']))
|
||||
|
||||
def test_plain_export_omits_credentials_and_import_preserves_them(self):
|
||||
profile = self.store.upsert('admin', {
|
||||
'hostname': 'example.com', 'username': 'root', 'password': 'secret'
|
||||
})
|
||||
exported = self.store.export_connections('admin')
|
||||
self.assertNotIn('password', exported[0])
|
||||
self.assertNotIn('owner', exported[0])
|
||||
exported[0].update(name='Imported', password='injected')
|
||||
self.store.import_connections('admin', exported)
|
||||
self.assertEqual('secret', self.store.get_password('admin', profile['id']))
|
||||
self.assertEqual('Imported', self.store.list('admin')[0]['name'])
|
||||
|
||||
def test_invalid_import_is_atomic(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.import_connections('admin', [
|
||||
{'hostname': 'example.com', 'username': 'root', 'port': 22},
|
||||
{'hostname': 'example.com', 'username': 'root', 'port': -1}
|
||||
])
|
||||
self.assertEqual([], self.store.list('admin'))
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.import_connections('admin', [
|
||||
{'hostname': 'example.com', 'username': 'root', 'port': 0}
|
||||
])
|
||||
|
||||
def test_encrypted_import_reencrypts_for_destination_server(self):
|
||||
profile = self.store.upsert('admin', {
|
||||
'hostname': 'example.com', 'username': 'root',
|
||||
'privatekey': 'key-material', 'passphrase': 'key-secret',
|
||||
'save_passphrase': True
|
||||
})
|
||||
exported = self.store.export_connections('admin', True)
|
||||
destination = ConnectionStore(self.filename + '.new', 'new-server')
|
||||
destination.import_connections('new-owner', exported, True)
|
||||
new_id = destination.make_id('new-owner', 'example.com', 22, 'root')
|
||||
self.assertEqual(('key-material', 'key-secret'),
|
||||
destination.get_privatekey('new-owner', new_id))
|
||||
with open(destination.filename, encoding='utf-8') as saved:
|
||||
self.assertNotIn('key-material', saved.read())
|
||||
self.assertEqual([], destination.list('admin'))
|
||||
|
||||
|
||||
class TestSecretBox(unittest.TestCase):
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from tornado.options import options
|
||||
from tornado.testing import AsyncHTTPTestCase
|
||||
|
||||
from webssh.main import make_app, make_handlers
|
||||
from webssh.settings import get_app_settings
|
||||
from webssh.worker import clients
|
||||
from webssh.workspace import (
|
||||
TRANSFER_LIMIT, decrypt_backup, encrypt_backup, transfer, validate_path
|
||||
)
|
||||
|
||||
|
||||
class TestBackup(unittest.TestCase):
|
||||
def test_roundtrip_wrong_password_and_tampering(self):
|
||||
connections = [{'hostname': 'example.com', 'password': 'secret'}]
|
||||
document = encrypt_backup(connections, 'long-backup-password')
|
||||
self.assertNotIn('secret', json.dumps(document))
|
||||
self.assertEqual(connections, decrypt_backup(document, 'long-backup-password'))
|
||||
with self.assertRaises(ValueError):
|
||||
decrypt_backup(document, 'wrong-backup-password')
|
||||
document['data'] = document['data'][:-4] + 'AAAA'
|
||||
with self.assertRaises(ValueError):
|
||||
decrypt_backup(document, 'long-backup-password')
|
||||
|
||||
def test_short_password_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
encrypt_backup([], 'short')
|
||||
|
||||
|
||||
class TestTransfer(unittest.TestCase):
|
||||
def make_sftp(self):
|
||||
ssh, sftp = Mock(), Mock()
|
||||
ssh.open_sftp.return_value = sftp
|
||||
sftp.normalize.side_effect = lambda path: path
|
||||
return ssh, sftp
|
||||
|
||||
def test_list_sorts_directories_before_files(self):
|
||||
ssh, sftp = self.make_sftp()
|
||||
sftp.listdir_iter.return_value = [
|
||||
Mock(filename='file', st_mode=stat.S_IFREG, st_size=10, st_mtime=0),
|
||||
Mock(filename='dir', st_mode=stat.S_IFDIR, st_size=0, st_mtime=0)
|
||||
]
|
||||
result = transfer(ssh, 'list', '/home')
|
||||
self.assertEqual(['dir', 'file'], [item['name'] for item in result['entries']])
|
||||
sftp.close.assert_called_once()
|
||||
sftp.get_channel().settimeout.assert_called_once_with(30)
|
||||
|
||||
def test_upload_is_exclusive_and_rejects_path_in_filename(self):
|
||||
ssh, sftp = self.make_sftp()
|
||||
remote = Mock()
|
||||
sftp.open.return_value.__enter__ = Mock(return_value=remote)
|
||||
sftp.open.return_value.__exit__ = Mock(return_value=False)
|
||||
transfer(ssh, 'upload', '/home', ('file.txt', b'content'))
|
||||
sftp.open.assert_called_once_with('/home/file.txt', 'wx')
|
||||
remote.write.assert_called_once_with(b'content')
|
||||
for filename in ('../secret', 'a/b', 'a\\b', '..'):
|
||||
with self.assertRaises(ValueError):
|
||||
transfer(ssh, 'upload', '/home', (filename, b'content'))
|
||||
|
||||
def test_download_enforces_size_and_regular_file(self):
|
||||
ssh, sftp = self.make_sftp()
|
||||
sftp.stat.return_value = Mock(st_mode=stat.S_IFREG, st_size=3)
|
||||
sftp.open.return_value = io.BytesIO(b'abc')
|
||||
self.assertEqual(b'abc', transfer(ssh, 'download', '/home/file'))
|
||||
sftp.stat.return_value.st_size = TRANSFER_LIMIT + 1
|
||||
with self.assertRaises(ValueError):
|
||||
transfer(ssh, 'download', '/home/file')
|
||||
sftp.stat.return_value = Mock(st_mode=stat.S_IFDIR, st_size=0)
|
||||
with self.assertRaises(ValueError):
|
||||
transfer(ssh, 'download', '/home')
|
||||
|
||||
def test_sftp_is_closed_when_upload_fails(self):
|
||||
ssh, sftp = self.make_sftp()
|
||||
sftp.open.side_effect = OSError('Already exists')
|
||||
with self.assertRaises(OSError):
|
||||
transfer(ssh, 'upload', '/home', ('existing', b'new'))
|
||||
sftp.close.assert_called_once()
|
||||
|
||||
def test_paths_reject_control_characters(self):
|
||||
for path in ('a\x00b', 'a\nb', 'x' * 4097):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_path(path)
|
||||
|
||||
|
||||
class TestWorkspaceAPI(AsyncHTTPTestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
super(TestWorkspaceAPI, self).setUp()
|
||||
self.store = self._app.settings['connection_store']
|
||||
|
||||
def tearDown(self):
|
||||
clients.pop('user:admin', None)
|
||||
super(TestWorkspaceAPI, self).tearDown()
|
||||
shutil.rmtree(self.tmpdir)
|
||||
|
||||
def get_app(self):
|
||||
options.auth = True
|
||||
options.auth_username = 'admin'
|
||||
options.auth_password = 'login-password'
|
||||
options.auth_password_hash = ''
|
||||
options.auth_session_days = 7
|
||||
options.data_dir = self.tmpdir
|
||||
options.debug = False
|
||||
options.xsrf = True
|
||||
options.policy = 'warning'
|
||||
options.hostfile = options.syshostfile = options.tdstream = ''
|
||||
options.origin = 'same'
|
||||
return make_app(make_handlers(self.io_loop, options), get_app_settings(options))
|
||||
|
||||
def headers(self):
|
||||
from tornado.web import create_signed_value
|
||||
cookie = create_signed_value(self._app.settings['cookie_secret'], 'webssh_user', 'admin')
|
||||
return {'Cookie': 'webssh_user=' + cookie.decode('utf-8') + '; _xsrf=token',
|
||||
'X-XSRFToken': 'token', 'Content-Type': 'application/json'}
|
||||
|
||||
def post_json(self, path, data, headers=None):
|
||||
return self.fetch(path, method='POST', body=json.dumps(data),
|
||||
headers=headers or self.headers(), follow_redirects=False)
|
||||
|
||||
def test_plain_export_and_metadata_keep_password_private(self):
|
||||
profile = self.store.upsert('admin', {'hostname': 'example.com', 'username': 'root', 'password': 'secret'})
|
||||
response = self.post_json('/connections/' + profile['id'] + '/metadata', {'name': 'Production'})
|
||||
self.assertEqual(200, response.code)
|
||||
self.assertNotIn(b'password_enc', response.body)
|
||||
response = self.post_json('/connections/export', {})
|
||||
self.assertEqual(200, response.code)
|
||||
self.assertNotIn(b'secret', response.body)
|
||||
self.assertEqual('Production', json.loads(response.body)['connections'][0]['name'])
|
||||
|
||||
def test_backup_is_encrypted_and_can_restore(self):
|
||||
self.store.upsert('admin', {'hostname': 'example.com', 'username': 'root', 'privatekey': 'key-material'})
|
||||
response = self.post_json('/connections/export', {'credentials': True, 'password': 'long-backup-password'})
|
||||
self.assertEqual(200, response.code)
|
||||
self.assertNotIn(b'key-material', response.body)
|
||||
document = json.loads(response.body)
|
||||
response = self.post_json('/connections/import', {'document': document, 'password': 'wrong-password'})
|
||||
self.assertEqual(400, response.code)
|
||||
response = self.post_json('/connections/import', {'document': document, 'password': 'long-backup-password'})
|
||||
self.assertEqual(200, response.code)
|
||||
self.assertNotIn(b'privatekey_enc', response.body)
|
||||
|
||||
def test_private_routes_require_login_and_xsrf_and_same_origin(self):
|
||||
for path in ('/connections/export', '/connections/import'):
|
||||
response = self.post_json(path, {}, {'Content-Type': 'application/json', 'Cookie': '_xsrf=token', 'X-XSRFToken': 'token'})
|
||||
self.assertEqual(403, response.code)
|
||||
headers = self.headers()
|
||||
headers.pop('X-XSRFToken')
|
||||
self.assertEqual(403, self.post_json('/connections/export', {}, headers).code)
|
||||
headers = self.headers()
|
||||
headers['Origin'] = 'https://other.example'
|
||||
self.assertEqual(403, self.post_json('/connections/export', {}, headers).code)
|
||||
|
||||
def test_file_worker_is_scoped_to_user_and_rejects_busy_sessions(self):
|
||||
response = self.fetch('/files/not-owned', headers=self.headers())
|
||||
self.assertEqual(409, response.code)
|
||||
worker = Mock(closed=False, handler=Mock(), file_lock=threading.Lock())
|
||||
worker.file_lock.acquire()
|
||||
clients['user:admin'] = {'busy': worker}
|
||||
response = self.fetch('/files/busy', headers=self.headers())
|
||||
self.assertEqual(409, response.code)
|
||||
worker.file_lock.release()
|
||||
|
||||
def test_workspace_controls_are_present(self):
|
||||
response = self.fetch('/', headers=self.headers())
|
||||
for element in ('session-tabs', 'terminal-search', 'terminal-theme',
|
||||
'command-list', 'files-panel', 'import-config',
|
||||
'connection-search', 'connection-group'):
|
||||
self.assertIn(('id="' + element + '"').encode('utf-8'), response.body)
|
||||
self.assertIn(b'workspace.js', response.body)
|
||||
Reference in New Issue
Block a user