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:
Jocay
2026-08-10 00:07:52 +08:00
parent af50427169
commit c013a389fe
21 changed files with 1396 additions and 62 deletions
+110
View File
@@ -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))
)