feat: Implement directory validation and shell command quoting
- Added is_valid_directory function to validate directory paths, ensuring they do not contain control characters and are within a specified length. - Introduced quote_shell_arg to safely quote shell arguments, preventing command injection. - Created build_cd_command to generate a command for changing directories in a shell. - Enhanced the LoginHandler to utilize a login rate limiter, preventing brute-force attacks by tracking failed login attempts. - Implemented an EncodingCache to optimize encoding detection for SSH connections. - Updated the UI to include an input field for specifying an initial directory upon login, with appropriate validation and hints. - Added a quickbar in the terminal interface for easy access to copy and paste functionality. - Introduced a toast notification system to provide feedback on copy actions. - Refactored connection storage to encrypt passwords at rest, improving security. - Updated various templates and styles to accommodate new features and improve user experience.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import tornado.websocket
|
||||
import tornado.gen
|
||||
@@ -29,6 +31,18 @@ server_encodings = {e.strip() for e in Server.encodings}
|
||||
|
||||
class TestAppBase(AsyncHTTPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
# keep auth.json/connections.json/cookie_secret out of the real
|
||||
# data directory while testing
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
options.data_dir = self.tmpdir
|
||||
handler.encoding_cache.clear()
|
||||
super(TestAppBase, self).setUp()
|
||||
|
||||
def tearDown(self):
|
||||
super(TestAppBase, self).tearDown()
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
def get_httpserver_options(self):
|
||||
return get_server_settings(options)
|
||||
|
||||
@@ -327,6 +341,48 @@ class TestAppBasic(TestAppBase):
|
||||
self.assertEqual(b'bye', msg)
|
||||
ws.close()
|
||||
|
||||
def test_app_with_invalid_directory(self):
|
||||
body = urlencode({
|
||||
'hostname': '127.0.0.1',
|
||||
'port': str(self.sshserver_port),
|
||||
'_xsrf': 'yummy',
|
||||
'username': 'robey',
|
||||
'password': 'foo',
|
||||
'directory': '/tmp\nreboot'
|
||||
})
|
||||
response = self.sync_post('/', body)
|
||||
self.assert_response(b'Invalid directory', response)
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_app_with_directory_runs_cd_after_login(self):
|
||||
body = urlencode({
|
||||
'hostname': '127.0.0.1',
|
||||
'port': str(self.sshserver_port),
|
||||
'_xsrf': 'yummy',
|
||||
'username': 'bar',
|
||||
'password': 'foo',
|
||||
'directory': "/var/o'brien"
|
||||
})
|
||||
url = self.get_url('/')
|
||||
response = yield self.async_post(url, body)
|
||||
data = json.loads(to_str(response.body))
|
||||
self.assert_status_none(data)
|
||||
|
||||
url = url.replace('http', 'ws')
|
||||
ws_url = url + 'ws?id=' + data['id']
|
||||
ws = yield tornado.websocket.websocket_connect(ws_url)
|
||||
|
||||
# the mock server echoes back whatever the shell channel receives;
|
||||
# the banner and the echo may or may not arrive in the same frame
|
||||
received = b''
|
||||
for _ in range(3):
|
||||
received += (yield ws.read_message())
|
||||
if b'cd ' in received:
|
||||
break
|
||||
|
||||
self.assertIn(b"cd '/var/o'\\''brien'\r", received)
|
||||
ws.close()
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_app_auth_with_valid_pubkey_by_urlencoded_form(self):
|
||||
url = self.get_url('/')
|
||||
@@ -792,3 +848,57 @@ class TestAppWithUnknownEncoding(OtherTestBase):
|
||||
dic = json.loads(to_str(response.body))
|
||||
self.assert_status_none(dic)
|
||||
self.assertEqual(dic['encoding'], 'utf-8')
|
||||
|
||||
|
||||
class TestAppWithSavedConnections(OtherTestBase):
|
||||
|
||||
def async_get(self, path):
|
||||
return self.get_http_client().fetch(
|
||||
self.get_url(path), headers=self.headers
|
||||
)
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_saved_password_never_reaches_the_browser(self):
|
||||
body = dict(self.body, save='1')
|
||||
response = yield self.async_post('/', body)
|
||||
data = json.loads(to_str(response.body))
|
||||
self.assert_status_none(data)
|
||||
|
||||
profile = data['profile']
|
||||
self.assertNotIn('password', profile)
|
||||
self.assertNotIn('password_enc', profile)
|
||||
self.assertTrue(profile['has_password'])
|
||||
|
||||
response = yield self.async_get('/connections')
|
||||
self.assertNotIn(b'"foo"', response.body)
|
||||
self.assertNotIn(b'password_enc', response.body)
|
||||
|
||||
listed = json.loads(to_str(response.body))['connections']
|
||||
self.assertEqual(1, len(listed))
|
||||
self.assertNotIn('password', listed[0])
|
||||
self.assertTrue(listed[0]['has_password'])
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_saved_password_is_reused_when_the_field_is_left_empty(self):
|
||||
body = dict(self.body, save='1')
|
||||
response = yield self.async_post('/', body)
|
||||
self.assert_status_none(json.loads(to_str(response.body)))
|
||||
|
||||
# no password in the form at all: the server supplies the stored one
|
||||
body = dict(self.body, password='')
|
||||
response = yield self.async_post('/', body)
|
||||
self.assert_status_none(json.loads(to_str(response.body)))
|
||||
|
||||
@tornado.testing.gen_test
|
||||
def test_saved_password_is_not_sent_to_another_destination(self):
|
||||
body = dict(self.body, save='1')
|
||||
response = yield self.async_post('/', body)
|
||||
self.assert_status_none(json.loads(to_str(response.body)))
|
||||
|
||||
# a different username means a different profile id, so the stored
|
||||
# credential must not be reused here
|
||||
body = dict(self.body, username='bar', password='')
|
||||
response = yield self.async_post('/', body)
|
||||
self.assert_status_equal(
|
||||
'Authentication failed.', json.loads(to_str(response.body))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user