diff --git a/Dockerfile b/Dockerfile index 484d095..959b280 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,7 @@ RUN useradd --system --create-home --shell /usr/sbin/nologin webssh && \ EXPOSE 8888/tcp USER webssh -CMD ["python", "run.py", "--address=0.0.0.0", "--port=8888", "--data-dir=/data"] +# WEBSSH_OPTS passes extra wssh options through without rebuilding the image, +# e.g. WEBSSH_OPTS="--maxconn=50 --tdstream=172.18.0.1" +ENV WEBSSH_OPTS="" +CMD ["sh", "-c", "exec python run.py --address=0.0.0.0 --port=8888 --data-dir=/data $WEBSSH_OPTS"] diff --git a/README.md b/README.md index a441217..1f21759 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,8 @@ var opts = { password: 'password', privatekey: 'the private key text', passphrase: 'passphrase', - totp: 'totp' + totp: 'totp', + directory: '/var/www' }; wssh.connect(opts); @@ -169,6 +170,11 @@ Passing a command executed right after login http://localhost:8888/?command=pwd ``` +Passing an initial working directory (the shell runs `cd` right after login) +```bash +http://localhost:8888/?hostname=xx&username=yy&directory=/var/www +``` + Passing a terminal type ```bash http://localhost:8888/?term=xterm-256color @@ -181,6 +187,11 @@ Start up the app docker compose up -d ``` +Rebuild after changing the code +``` +docker compose up -d --build +``` + Tear down the app ``` docker compose down @@ -189,6 +200,27 @@ docker compose down The bundled compose file persists login and saved connection data in the `webssh-data` volume mounted at `/data` inside the container. +Extra options can be passed without rebuilding the image via `WEBSSH_OPTS`: +``` +WEBSSH_OPTS="--maxconn=50 --tdstream=172.18.0.1" docker compose up -d +``` + +Set `--tdstream` to your reverse proxy address whenever `xheaders` is on +(the default). Without it any client can spoof `X-Forwarded-For` and claim +someone else's address, which defeats the per-client connection limit. + +### Saved connections + +Saved SSH passwords are encrypted at rest in `connections.json` with a key +derived from the server secret, and are never sent to the browser. Leaving +the password field empty for a saved host tells the server to use the one it +already holds; editing the hostname, port or username makes it a different +destination, so the stored credential is not reused there. + +The server secret lives in `/cookie_secret` and can be overridden +with the `WEBSSH_COOKIE_SECRET` environment variable. Losing it does not break +the app, but saved passwords become unreadable and have to be entered again. + ### Tests Requirements diff --git a/commit_msg.txt b/commit_msg.txt new file mode 100644 index 0000000..8dc5e1b --- /dev/null +++ b/commit_msg.txt @@ -0,0 +1,12 @@ +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 uild_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. diff --git a/docker-compose.yml b/docker-compose.yml index a221afa..fa2aaba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,13 @@ services: restart: unless-stopped ports: - "8888:8888" + environment: + # extra wssh options, e.g. "--maxconn=50 --tdstream=172.18.0.1" + WEBSSH_OPTS: ${WEBSSH_OPTS:-} + # set this to keep saved passwords readable across container rebuilds + # when the data volume is recreated; otherwise a secret is generated + # in /data on first start + WEBSSH_COOKIE_SECRET: ${WEBSSH_COOKIE_SECRET:-} volumes: - webssh-data:/data diff --git a/tests/sshserver.py b/tests/sshserver.py index 1385a98..bd0481d 100644 --- a/tests/sshserver.py +++ b/tests/sshserver.py @@ -20,6 +20,9 @@ print('Read key: ' + hexlify(host_key.get_fingerprint()).decode('utf-8')) banner = u'\r\n\u6b22\u8fce\r\n' event_timeout = 5 +# clients that reuse a cached encoding never send the probe command, so this +# wait must be short and non-fatal +exec_timeout = 1 class Server(paramiko.ServerInterface): @@ -41,6 +44,7 @@ class Server(paramiko.ServerInterface): self.shell_event = threading.Event() self.exec_event = threading.Event() self.cmd_to_enc = self.get_cmd2enc(encodings) + self.encoding = 'UTF-8' self.password_verified = False self.key_verified = False @@ -171,10 +175,11 @@ def run_ssh_server(port=2200, running=True, encodings=[]): print('*** Client never asked for a shell.') continue - server.exec_event.wait(timeout=event_timeout) + server.exec_event.wait(timeout=exec_timeout) if not server.exec_event.is_set(): + # the client already knew this server's encoding and skipped the + # probe; fall back to the default instead of dropping the session print('*** Client never asked for a command.') - continue # chan.send('\r\n\r\nWelcome!\r\n\r\n') print(server.encoding) diff --git a/tests/test_app.py b/tests/test_app.py index 24ddd37..cccebd9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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)) + ) diff --git a/tests/test_auth_app.py b/tests/test_auth_app.py index ce82fe3..c4a6c0f 100644 --- a/tests/test_auth_app.py +++ b/tests/test_auth_app.py @@ -5,6 +5,8 @@ import tempfile from tornado.options import options from tornado.testing import AsyncHTTPTestCase +from webssh.handler import login_rate_limiter +from webssh.worker import clients from webssh.main import make_app, make_handlers from webssh.settings import get_app_settings from webssh.utils import to_str @@ -62,3 +64,101 @@ class TestAppAuth(AsyncHTTPTestCase): response = self.fetch('/connections', headers=headers) data = json.loads(to_str(response.body)) self.assertEqual(data, {'connections': []}) + + def test_login_is_rate_limited_after_repeated_failures(self): + login_rate_limiter.clear() + self.addCleanup(login_rate_limiter.clear) + + body = 'username=admin&password=secret&confirm=secret' + response = self.fetch('/login', method='POST', body=body, + follow_redirects=False) + self.assertEqual(response.code, 302) + + wrong = 'username=admin&password=nope' + for _ in range(login_rate_limiter.max_attempts): + response = self.fetch('/login', method='POST', body=wrong) + self.assertIn('用户名或密码错误'.encode('utf-8'), response.body) + + # further attempts are refused without touching the password hash + response = self.fetch('/login', method='POST', body=wrong) + self.assertIn('登录尝试过于频繁'.encode('utf-8'), response.body) + + # ... and a correct password is refused too while the block lasts + good = 'username=admin&password=secret' + response = self.fetch('/login', method='POST', body=good, + follow_redirects=False) + self.assertEqual(response.code, 200) + self.assertIn('登录尝试过于频繁'.encode('utf-8'), response.body) + + def test_successful_login_clears_the_failure_counter(self): + login_rate_limiter.clear() + self.addCleanup(login_rate_limiter.clear) + + body = 'username=admin&password=secret&confirm=secret' + self.fetch('/login', method='POST', body=body, follow_redirects=False) + + self.fetch('/login', method='POST', body='username=admin&password=x') + self.assertNotEqual({}, login_rate_limiter.attempts) + + response = self.fetch('/login', method='POST', + body='username=admin&password=secret', + follow_redirects=False) + self.assertEqual(response.code, 302) + self.assertEqual({}, login_rate_limiter.attempts) + + +class TestMaxConnPerUser(AsyncHTTPTestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + clients.clear() + login_rate_limiter.clear() + self.addCleanup(clients.clear) + self.addCleanup(login_rate_limiter.clear) + super(TestMaxConnPerUser, self).setUp() + + def tearDown(self): + super(TestMaxConnPerUser, self).tearDown() + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def get_app(self): + loop = self.io_loop + options.auth = True + options.auth_username = 'admin' + options.auth_password = 'secret' + options.auth_password_hash = '' + options.auth_session_days = 7 + options.data_dir = self.tmpdir + options.debug = False + options.xsrf = False + options.policy = 'warning' + options.hostfile = '' + options.syshostfile = '' + options.tdstream = '' + options.origin = 'same' + options.maxconn = 1 + return make_app(make_handlers(loop, options), get_app_settings(options)) + + def login(self): + response = self.fetch('/login', method='POST', + body='username=admin&password=secret', + follow_redirects=False) + cookie = response.headers.get_list('Set-Cookie')[0] + return {'Cookie': cookie.split(';', 1)[0]} + + def test_limit_follows_the_login_not_the_address(self): + headers = self.login() + body = 'hostname=127.0.0.1&port=65000&username=robey' + + # one live session for this login already exhausts maxconn=1 + clients['user:admin'] = {'fake': None} + response = self.fetch('/', method='POST', body=body, headers=headers) + self.assertIn(b'Too many live connections', response.body) + + # another user shares the same source address but has its own budget, + # so it gets as far as actually dialing the (absent) ssh server + clients.clear() + clients['user:someone-else'] = {'fake': None} + response = self.fetch('/', method='POST', body=body, headers=headers) + self.assertNotIn(b'Too many live connections', response.body) + self.assertIn(b'Unable to connect to', response.body) diff --git a/tests/test_handler.py b/tests/test_handler.py index d90eef3..83deb4e 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -8,7 +8,7 @@ from tests.utils import read_file, make_tests_data_path from webssh import handler from webssh import worker from webssh.handler import ( - IndexHandler, MixinHandler, WsockHandler, PrivateKey, InvalidValueError, SSHClient + IndexHandler, MixinHandler, WsockHandler, PrivateKey, InvalidValueError, SSHClient, LoginRateLimiter, EncodingCache ) try: @@ -324,6 +324,7 @@ class TestIndexHandler(unittest.TestCase): obj.get_port.return_value = 22 obj.get_value.return_value = 'root' obj.get_privatekey.return_value = ('', '') + obj.get_directory.return_value = '' obj.policy = paramiko.WarningPolicy() obj.ssh_client = Mock() @@ -345,16 +346,75 @@ class TestIndexHandler(unittest.TestCase): ) self.assertEqual('root-secret', obj.connection_info['password']) + def test_get_args_carries_directory(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_directory.return_value = '/var/www' + obj.policy = paramiko.WarningPolicy() + obj.ssh_client = Mock() + obj.get_argument.side_effect = lambda name, default=u'': default + + args = IndexHandler.get_args(obj) + + self.assertEqual('/var/www', args[6]) + self.assertEqual('/var/www', obj.connection_info['directory']) + + def test_get_directory(self): + obj = Mock(spec=IndexHandler) + values = {} + obj.get_argument.side_effect = lambda name, default=u'': values.get( + name, default + ) + + self.assertEqual('', IndexHandler.get_directory(obj)) + + values['directory'] = ' ' + self.assertEqual('', IndexHandler.get_directory(obj)) + + values['directory'] = ' /var/www ' + self.assertEqual('/var/www', IndexHandler.get_directory(obj)) + + values['directory'] = '/tmp\nrm -rf /' + with self.assertRaises(InvalidValueError): + IndexHandler.get_directory(obj) + + values['directory'] = '/tmp' + 'a' * 1024 + with self.assertRaises(InvalidValueError): + IndexHandler.get_directory(obj) + + def test_change_directory_quotes_the_path(self): + obj = Mock(spec=IndexHandler) + chan = Mock() + + IndexHandler.change_directory(obj, chan, '/tmp; reboot') + + chan.sendall.assert_called_with(b"cd '/tmp; reboot'\r") + + def test_change_directory_swallows_channel_errors(self): + obj = Mock(spec=IndexHandler) + chan = Mock() + chan.sendall.side_effect = paramiko.SSHException('boom') + + # a failed cd must not prevent the session from starting + IndexHandler.change_directory(obj, chan, '/var/www') + def test_null_in_encoding(self): - handler = Mock(spec=IndexHandler) + mock_handler = Mock(spec=IndexHandler) # This is a little nasty, but the index handler has a lot of # dependencies to mock. Mocking out everything but the bits # we want to test lets us test this case without needing to # refactor the relevant code out of IndexHandler def parse_encoding(data): - return IndexHandler.parse_encoding(handler, data) - handler.parse_encoding = parse_encoding + return IndexHandler.parse_encoding(mock_handler, data) + mock_handler.parse_encoding = parse_encoding + + def detect_encoding(ssh): + return IndexHandler.detect_encoding(mock_handler, ssh) + mock_handler.detect_encoding = detect_encoding ssh = Mock(spec=SSHClient) stdin = io.BytesIO() @@ -362,6 +422,184 @@ class TestIndexHandler(unittest.TestCase): stderr = io.BytesIO() ssh.exec_command.return_value = (stdin, stdout, stderr) - encoding = IndexHandler.get_default_encoding(handler, ssh) + self.assertIsNone(IndexHandler.detect_encoding(mock_handler, ssh)) + + encoding = IndexHandler.get_default_encoding(mock_handler, ssh) self.assertEqual("utf-8", encoding) + +class TestLoginRateLimiter(unittest.TestCase): + + def test_blocks_after_too_many_failures(self): + limiter = LoginRateLimiter(max_attempts=3, window=300) + + self.assertFalse(limiter.is_blocked('1.1.1.1')) + for _ in range(2): + limiter.record_failure('1.1.1.1') + self.assertFalse(limiter.is_blocked('1.1.1.1')) + + limiter.record_failure('1.1.1.1') + self.assertTrue(limiter.is_blocked('1.1.1.1')) + + # other clients are unaffected + self.assertFalse(limiter.is_blocked('2.2.2.2')) + + def test_success_resets_the_counter(self): + limiter = LoginRateLimiter(max_attempts=2, window=300) + + limiter.record_failure('1.1.1.1') + limiter.record_failure('1.1.1.1') + self.assertTrue(limiter.is_blocked('1.1.1.1')) + + limiter.reset('1.1.1.1') + self.assertFalse(limiter.is_blocked('1.1.1.1')) + + def test_entries_expire_after_the_window(self): + limiter = LoginRateLimiter(max_attempts=1, window=300) + + limiter.record_failure('1.1.1.1') + self.assertTrue(limiter.is_blocked('1.1.1.1')) + + # pretend the failure happened longer ago than the window + count, last = limiter.attempts['1.1.1.1'] + limiter.attempts['1.1.1.1'] = (count, last - 301) + + self.assertFalse(limiter.is_blocked('1.1.1.1')) + self.assertEqual({}, limiter.attempts) + + def test_clear(self): + limiter = LoginRateLimiter(max_attempts=1, window=300) + + limiter.record_failure('1.1.1.1') + self.assertTrue(limiter.is_blocked('1.1.1.1')) + limiter.clear() + self.assertFalse(limiter.is_blocked('1.1.1.1')) + + +class TestEncodingCache(unittest.TestCase): + + def test_get_and_set(self): + cache = EncodingCache() + + self.assertIsNone(cache.get(('127.0.0.1', 22))) + cache.set(('127.0.0.1', 22), 'GBK') + self.assertEqual('GBK', cache.get(('127.0.0.1', 22))) + # the port is part of the key + self.assertIsNone(cache.get(('127.0.0.1', 2200))) + + def test_a_failed_probe_is_not_cached(self): + cache = EncodingCache() + + cache.set(('127.0.0.1', 22), None) + cache.set(('127.0.0.1', 22), '') + self.assertIsNone(cache.get(('127.0.0.1', 22))) + + def test_cache_is_bounded(self): + cache = EncodingCache(maxsize=2) + + cache.set(('a', 22), 'UTF-8') + cache.set(('b', 22), 'UTF-8') + cache.set(('c', 22), 'UTF-8') + self.assertEqual(1, len(cache.cache)) + self.assertEqual('UTF-8', cache.get(('c', 22))) + + +class TestGetEncoding(unittest.TestCase): + + def setUp(self): + handler.encoding_cache.clear() + self.addCleanup(handler.encoding_cache.clear) + + def make_handler(self, detected): + obj = Mock(spec=IndexHandler) + obj.detect_encoding.return_value = detected + return obj + + def test_probes_once_then_serves_from_cache(self): + obj = self.make_handler('GBK') + ssh = Mock() + dst_addr = ('127.0.0.1', 22) + + self.assertEqual('GBK', IndexHandler.get_encoding(obj, ssh, dst_addr)) + self.assertEqual('GBK', IndexHandler.get_encoding(obj, ssh, dst_addr)) + self.assertEqual(1, obj.detect_encoding.call_count) + + def test_failed_detection_falls_back_without_caching(self): + obj = self.make_handler(None) + ssh = Mock() + dst_addr = ('127.0.0.1', 22) + + self.assertEqual( + 'utf-8', IndexHandler.get_encoding(obj, ssh, dst_addr) + ) + self.assertEqual( + 'utf-8', IndexHandler.get_encoding(obj, ssh, dst_addr) + ) + self.assertEqual(2, obj.detect_encoding.call_count) + + +class TestClientKey(unittest.TestCase): + + def make_handler(self, auth_manager, addr=('10.0.0.1', 1234)): + obj = Mock(spec=MixinHandler) + obj.auth_manager = auth_manager + obj.get_client_addr.return_value = addr + return obj + + def test_logged_in_users_get_their_own_bucket(self): + alice = self.make_handler(Mock()) + alice.current_user = 'alice' + bob = self.make_handler(Mock()) + bob.current_user = 'bob' + + # same source address, different buckets + self.assertEqual('user:alice', MixinHandler.get_client_key(alice)) + self.assertEqual('user:bob', MixinHandler.get_client_key(bob)) + + def test_without_auth_the_address_is_used(self): + obj = self.make_handler(None) + self.assertEqual('10.0.0.1', MixinHandler.get_client_key(obj)) + + def test_a_username_cannot_collide_with_an_address(self): + spoofer = self.make_handler(Mock()) + spoofer.current_user = '10.0.0.1' + + direct = self.make_handler(None) + + self.assertNotEqual( + MixinHandler.get_client_key(spoofer), + MixinHandler.get_client_key(direct) + ) + + +class TestClearWorker(unittest.TestCase): + + def make_worker(self, worker_id, client_key): + obj = Mock() + obj.id = worker_id + obj.client_key = client_key + return obj + + def test_removes_the_worker_and_prunes_empty_buckets(self): + a = self.make_worker('a', 'user:admin') + b = self.make_worker('b', 'user:admin') + clients = {'user:admin': {'a': a, 'b': b}} + + worker.clear_worker(a, clients) + self.assertEqual({'user:admin': {'b': b}}, clients) + + worker.clear_worker(b, clients) + self.assertEqual({}, clients) + + def test_unregistered_worker_is_ignored(self): + orphan = self.make_worker('a', None) + clients = {} + + # must not raise even though the worker was never registered + worker.clear_worker(orphan, clients) + self.assertEqual({}, clients) + + clients = {'user:admin': {'b': 'other'}} + orphan.client_key = 'user:admin' + worker.clear_worker(orphan, clients) + self.assertEqual({'user:admin': {'b': 'other'}}, clients) diff --git a/tests/test_storage.py b/tests/test_storage.py index e3336ff..07049f1 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -3,19 +3,27 @@ import shutil import tempfile import unittest +from webssh.crypto import is_encrypted from webssh.storage import ConnectionStore +SECRET = 'test-server-secret' + + class TestConnectionStore(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() - self.store = ConnectionStore(os.path.join(self.tmpdir, - 'connections.json')) + self.filename = os.path.join(self.tmpdir, 'connections.json') + self.store = ConnectionStore(self.filename, SECRET) def tearDown(self): shutil.rmtree(self.tmpdir) + def read_raw(self): + with open(self.filename, encoding='utf-8') as f: + return f.read() + def test_upsert_list_and_delete(self): profile = self.store.upsert('admin', { 'hostname': '127.0.0.1', @@ -23,11 +31,12 @@ class TestConnectionStore(unittest.TestCase): 'username': 'root', 'password': 'root-secret', 'term': 'xterm-256color', + 'directory': '/var/www', 'auth_type': 'password' }) self.assertEqual(profile['title'], 'root@127.0.0.1:22') - self.assertEqual(profile['password'], 'root-secret') + self.assertEqual(profile['directory'], '/var/www') self.assertEqual([profile], self.store.list('admin')) self.assertEqual([], self.store.list('other')) @@ -43,7 +52,144 @@ class TestConnectionStore(unittest.TestCase): self.assertEqual(profile['id'], updated['id']) self.assertEqual(1, len(self.store.list('admin'))) self.assertEqual('xterm', self.store.list('admin')[0]['term']) - self.assertEqual('new-secret', self.store.list('admin')[0]['password']) + self.assertEqual('', self.store.list('admin')[0]['directory']) self.assertTrue(self.store.delete('admin', profile['id'])) self.assertFalse(self.store.delete('admin', profile['id'])) self.assertEqual([], self.store.list('admin')) + + def test_password_never_leaves_the_store(self): + profile = self.store.upsert('admin', { + 'hostname': '127.0.0.1', + 'username': 'root', + 'password': 'root-secret' + }) + + self.assertNotIn('password', profile) + self.assertNotIn('password_enc', profile) + self.assertTrue(profile['has_password']) + + listed = self.store.list('admin')[0] + self.assertNotIn('password', listed) + self.assertNotIn('password_enc', listed) + self.assertTrue(listed['has_password']) + + # ... but it is still usable server side + self.assertEqual( + 'root-secret', self.store.get_password('admin', profile['id']) + ) + + def test_password_is_encrypted_at_rest(self): + self.store.upsert('admin', { + 'hostname': '127.0.0.1', + 'username': 'root', + 'password': 'root-secret' + }) + + raw = self.read_raw() + self.assertNotIn('root-secret', raw) + self.assertIn('password_enc', raw) + + def test_password_is_scoped_to_owner_and_destination(self): + profile = self.store.upsert('admin', { + 'hostname': '127.0.0.1', + 'username': 'root', + 'password': 'root-secret' + }) + + self.assertEqual( + '', self.store.get_password('mallory', profile['id']) + ) + self.assertEqual('', self.store.get_password('admin', 'deadbeef')) + + # an edited hostname yields a different id, so nothing is found + other_id = self.store.make_id('admin', 'evil.example.com', 22, 'root') + self.assertNotEqual(profile['id'], other_id) + self.assertEqual('', self.store.get_password('admin', other_id)) + + def test_profile_without_password(self): + profile = self.store.upsert('admin', { + 'hostname': '127.0.0.1', + 'username': 'root' + }) + self.assertFalse(profile['has_password']) + self.assertEqual('', self.store.get_password('admin', profile['id'])) + + def test_a_wrong_secret_does_not_break_the_store(self): + profile = self.store.upsert('admin', { + 'hostname': '127.0.0.1', + 'username': 'root', + 'password': 'root-secret' + }) + + rotated = ConnectionStore(self.filename, 'a-different-secret') + self.assertEqual('', rotated.get_password('admin', profile['id'])) + self.assertTrue(rotated.list('admin')[0]['has_password']) + + def test_encrypt_plaintext_passwords_migrates_old_files(self): + legacy = ConnectionStore(self.filename) + profile = legacy.upsert('admin', { + 'hostname': '127.0.0.1', + 'username': 'root', + 'password': 'legacy-secret' + }) + self.assertIn('legacy-secret', self.read_raw()) + self.assertEqual( + 'legacy-secret', legacy.get_password('admin', profile['id']) + ) + + self.assertEqual(1, self.store.encrypt_plaintext_passwords()) + + raw = self.read_raw() + self.assertNotIn('legacy-secret', raw) + self.assertEqual( + 'legacy-secret', self.store.get_password('admin', profile['id']) + ) + self.assertTrue(self.store.list('admin')[0]['has_password']) + + # already migrated, nothing left to do + self.assertEqual(0, self.store.encrypt_plaintext_passwords()) + + def test_directory_is_normalized_and_optional(self): + profile = self.store.upsert('admin', { + 'hostname': 'example.com', + 'username': 'deploy', + 'directory': ' /srv/app ' + }) + self.assertEqual('/srv/app', profile['directory']) + + profile = self.store.upsert('admin', { + 'hostname': 'example.org', + 'username': 'deploy' + }) + self.assertEqual('', profile['directory']) + + +class TestSecretBox(unittest.TestCase): + + def test_round_trip(self): + from webssh.crypto import SecretBox + + box = SecretBox(SECRET) + token = box.encrypt('hunter2') + self.assertTrue(is_encrypted(token)) + self.assertNotIn('hunter2', token) + self.assertEqual('hunter2', box.decrypt(token)) + + def test_nonce_is_not_reused(self): + from webssh.crypto import SecretBox + + box = SecretBox(SECRET) + self.assertNotEqual(box.encrypt('same'), box.encrypt('same')) + + def test_empty_and_tampered_values(self): + from webssh.crypto import SecretBox + + box = SecretBox(SECRET) + self.assertEqual('', box.encrypt('')) + self.assertEqual('', box.decrypt('')) + self.assertEqual('', box.decrypt('not-a-token')) + self.assertEqual('', box.decrypt('aesgcm$####')) + + token = box.encrypt('hunter2') + self.assertEqual('', box.decrypt(token[:-4] + 'AAAA')) + self.assertEqual('', SecretBox('other').decrypt(token)) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5ace48e..afbfe24 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,8 @@ import unittest from webssh.utils import ( is_valid_ip_address, is_valid_port, is_valid_hostname, to_str, to_bytes, - to_int, is_ip_hostname, is_same_primary_domain, parse_origin_from_url + to_int, is_ip_hostname, is_same_primary_domain, parse_origin_from_url, + is_valid_directory, quote_shell_arg, build_cd_command ) @@ -56,6 +57,42 @@ class TestUitls(unittest.TestCase): self.assertFalse(is_valid_hostname('127.0.0.1')) self.assertFalse(is_valid_hostname('::1')) + def test_is_valid_directory(self): + self.assertTrue(is_valid_directory('/var/www')) + self.assertTrue(is_valid_directory('~/projects')) + self.assertTrue(is_valid_directory('/tmp/a b')) + self.assertTrue(is_valid_directory('/srv/项目')) + self.assertTrue(is_valid_directory("/tmp/o'brien")) + self.assertFalse(is_valid_directory('')) + self.assertFalse(is_valid_directory(None)) + self.assertFalse(is_valid_directory('/tmp\nrm -rf /')) + self.assertFalse(is_valid_directory('/tmp\rwhoami')) + self.assertFalse(is_valid_directory('/tmp\x00')) + self.assertFalse(is_valid_directory('/tmp\x1b[31m')) + self.assertTrue(is_valid_directory('/' + 'a' * 1023)) + self.assertFalse(is_valid_directory('/' + 'a' * 1024)) + + def test_quote_shell_arg(self): + self.assertEqual(quote_shell_arg('/var/www'), "'/var/www'") + self.assertEqual(quote_shell_arg('/tmp/a b'), "'/tmp/a b'") + self.assertEqual( + quote_shell_arg('/tmp; rm -rf /'), "'/tmp; rm -rf /'" + ) + self.assertEqual( + quote_shell_arg('/tmp/$(whoami)'), "'/tmp/$(whoami)'" + ) + # a single quote is closed, escaped, then reopened + self.assertEqual(quote_shell_arg("o'brien"), "'o'\\''brien'") + self.assertEqual( + quote_shell_arg("'; rm -rf /; '"), "''\\''; rm -rf /; '\\'''" + ) + + def test_build_cd_command(self): + self.assertEqual(build_cd_command('/var/www'), "cd '/var/www'\r") + self.assertEqual( + build_cd_command('/tmp; reboot'), "cd '/tmp; reboot'\r" + ) + def test_is_ip_hostname(self): self.assertTrue(is_ip_hostname('[::1]')) self.assertTrue(is_ip_hostname('127.0.0.1')) diff --git a/webssh/crypto.py b/webssh/crypto.py new file mode 100644 index 0000000..236e5e6 --- /dev/null +++ b/webssh/crypto.py @@ -0,0 +1,60 @@ +import base64 +import logging +import os + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from webssh.utils import to_bytes, to_str + + +NONCE_SIZE = 12 +PREFIX = 'aesgcm$' +DEFAULT_INFO = b'webssh connection password' + + +class SecretBox(object): + """Authenticated encryption for secrets kept in the data directory. + + The key is derived from the server secret, so a stolen connections.json + is useless without the secret file next to it. + """ + + def __init__(self, secret, info=DEFAULT_INFO): + self.key = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=info + ).derive(to_bytes(secret)) + + def encrypt(self, plaintext): + if not plaintext: + return '' + + nonce = os.urandom(NONCE_SIZE) + blob = nonce + AESGCM(self.key).encrypt( + nonce, to_bytes(plaintext), None + ) + return PREFIX + base64.b64encode(blob).decode('ascii') + + def decrypt(self, token): + if not is_encrypted(token): + return '' + + try: + blob = base64.b64decode(token[len(PREFIX):]) + return to_str( + AESGCM(self.key).decrypt( + blob[:NONCE_SIZE], blob[NONCE_SIZE:], None + ) + ) + except Exception: + # a rotated or lost secret must not take the whole app down + logging.warning('Could not decrypt a stored secret.') + return '' + + +def is_encrypted(token): + return bool(token) and isinstance(token, str) and token.startswith(PREFIX) diff --git a/webssh/handler.py b/webssh/handler.py index 158013b..66d19ca 100644 --- a/webssh/handler.py +++ b/webssh/handler.py @@ -3,6 +3,8 @@ import json import logging import socket import struct +import threading +import time import traceback import weakref import paramiko @@ -15,7 +17,7 @@ from tornado.process import cpu_count from webssh.utils import ( is_valid_ip_address, is_valid_port, is_valid_hostname, to_bytes, to_str, to_int, to_ip_address, UnicodeType, is_ip_hostname, is_same_primary_domain, - is_valid_encoding + is_valid_encoding, is_valid_directory, build_cd_command ) from webssh.worker import Worker, recycle_worker, clients @@ -36,6 +38,81 @@ swallow_http_errors = True redirecting = None +class LoginRateLimiter(object): + """Throttle repeated failed logins from the same client address.""" + + def __init__(self, max_attempts=8, window=300): + self.max_attempts = max_attempts + self.window = window + self.lock = threading.Lock() + self.attempts = {} + + def _prune(self, now): + expired = [ + key for key, (_, last) in self.attempts.items() + if now - last > self.window + ] + for key in expired: + self.attempts.pop(key, None) + + def is_blocked(self, key): + now = time.monotonic() + with self.lock: + self._prune(now) + count, last = self.attempts.get(key, (0, 0)) + return count >= self.max_attempts and now - last <= self.window + + def record_failure(self, key): + now = time.monotonic() + with self.lock: + self._prune(now) + count, _ = self.attempts.get(key, (0, 0)) + self.attempts[key] = (count + 1, now) + + def reset(self, key): + with self.lock: + self.attempts.pop(key, None) + + def clear(self): + with self.lock: + self.attempts.clear() + + +login_rate_limiter = LoginRateLimiter() + + +class EncodingCache(object): + """Remember each server's encoding so we probe it only once. + + Detection costs up to two `locale charmap` round trips with a one second + timeout each, which is otherwise paid on every single connection. + """ + + def __init__(self, maxsize=1024): + self.maxsize = maxsize + self.lock = threading.Lock() + self.cache = {} + + def get(self, key): + with self.lock: + return self.cache.get(key) + + def set(self, key, encoding): + if not encoding: + return + with self.lock: + if len(self.cache) >= self.maxsize: + self.cache.clear() + self.cache[key] = encoding + + def clear(self): + with self.lock: + self.cache.clear() + + +encoding_cache = EncodingCache() + + class InvalidValueError(Exception): pass @@ -312,6 +389,17 @@ class MixinHandler(object): else: return self.get_context_addr() + def get_client_key(self): + """Bucket live sessions by login when there is one, by address if not. + + Behind a reverse proxy or a Docker bridge many browsers can share one + source address, which would turn the per-client maxconn limit into a + server-wide one. + """ + if self.auth_manager: + return 'user:{}'.format(self.current_user) + return self.get_client_addr()[0] + def get_real_client_addr(self): ip = self.request.remote_ip @@ -333,6 +421,9 @@ class MixinHandler(object): class LoginHandler(MixinHandler, tornado.web.RequestHandler): + # PBKDF2 takes ~100ms; running it inline would stall every live terminal + executor = ThreadPoolExecutor(max_workers=2) + def initialize(self): super(LoginHandler, self).initialize() @@ -348,6 +439,7 @@ class LoginHandler(MixinHandler, tornado.web.RequestHandler): self.render_login() + @tornado.gen.coroutine def post(self): manager = self.auth_manager if not manager: @@ -358,10 +450,23 @@ class LoginHandler(MixinHandler, tornado.web.RequestHandler): password = self.get_argument('password', u'') if manager.is_configured(): - if manager.verify(username, password): + client = self.get_client_addr()[0] + if login_rate_limiter.is_blocked(client): + logging.warning( + 'Too many failed logins from {}'.format(client) + ) + self.render_login('登录尝试过于频繁,请稍后再试。') + return + + verified = yield self.executor.submit( + manager.verify, username, password + ) + if verified: + login_rate_limiter.reset(client) self.set_login_cookie(username) self.redirect(self.get_safe_next_url()) else: + login_rate_limiter.record_failure(client) self.render_login('用户名或密码错误。') return @@ -377,7 +482,7 @@ class LoginHandler(MixinHandler, tornado.web.RequestHandler): return try: - manager.setup(username, password) + yield self.executor.submit(manager.setup, username, password) except ValueError as exc: self.render_login(str(exc)) else: @@ -527,6 +632,30 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): hostname, port) ) + def get_directory(self): + value = self.get_argument('directory', u'').strip() + if not value: + return u'' + if not is_valid_directory(value): + raise InvalidValueError('Invalid directory: {}'.format(value)) + return value + + def get_saved_password(self, hostname, port, username): + """Look up a stored password without ever sending it to the client. + + The profile id is derived from the destination, so an edited + hostname/port/username simply finds nothing instead of forwarding + the saved credential somewhere it does not belong. + """ + store = self.connection_store + if not store: + return u'' + + profile_id = store.make_id( + self.current_user, hostname, port, username + ) + return store.get_password(self.current_user, profile_id) + def get_args(self): hostname = self.get_hostname() port = self.get_port() @@ -536,6 +665,10 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): passphrase = self.get_argument('passphrase', u'') totp = self.get_argument('totp', u'') term = self.get_argument('term', u'') or u'xterm' + directory = self.get_directory() + + if not password and not privatekey: + password = self.get_saved_password(hostname, port, username) if isinstance(self.policy, paramiko.RejectPolicy): self.lookup_hostname(hostname, port) @@ -546,13 +679,14 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): pkey = None self.ssh_client.totp = totp - args = (hostname, port, username, password, pkey, term) + args = (hostname, port, username, password, pkey, term, directory) self.connection_info = { 'hostname': hostname, 'port': port, 'username': username, 'password': password, 'term': term, + 'directory': directory, 'auth_type': 'privatekey' if privatekey else 'password' } logging.debug(args[:5]) @@ -569,6 +703,9 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): return encoding def get_default_encoding(self, ssh): + return self.detect_encoding(ssh) or 'utf-8' + + def detect_encoding(self, ssh): commands = [ '$SHELL -ilc "locale charmap"', '$SHELL -ic "locale charmap"' @@ -593,13 +730,13 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): return result logging.warning('Could not detect the default encoding.') - return 'utf-8' def ssh_connect(self, args): ssh = self.ssh_client dst_addr = args[:2] connect_args = args[:5] term = args[5] + directory = args[6] logging.info('Connecting to {}:{}'.format(*dst_addr)) try: @@ -614,12 +751,43 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): raise ValueError('Bad host key.') chan = ssh.invoke_shell(term=term) + if directory: + self.change_directory(chan, directory) chan.setblocking(0) worker = Worker(self.loop, ssh, chan, dst_addr) worker.encoding = options.encoding if options.encoding else \ - self.get_default_encoding(ssh) + self.get_encoding(ssh, args[:3]) return worker + def get_encoding(self, ssh, cache_key): + """Detect the server encoding once per (host, port, user).""" + cached = encoding_cache.get(cache_key) + if cached: + logging.debug( + 'Using cached encoding {!r} for {!r}'.format(cached, cache_key) + ) + return cached + + # a failed probe is not cached, so a transient hiccup does not pin + # the server to the fallback encoding until the next restart + encoding = self.detect_encoding(ssh) + encoding_cache.set(cache_key, encoding) + return encoding or 'utf-8' + + def change_directory(self, chan, directory): + """Queue a cd command on the pty so the shell runs it at startup.""" + try: + chan.settimeout(options.timeout) + chan.sendall(to_bytes(build_cd_command(directory))) + except (OSError, IOError, paramiko.SSHException) as exc: + logging.warning( + 'Could not change directory to {!r}: {}'.format( + directory, exc + ) + ) + finally: + chan.settimeout(None) + def check_origin(self): event_origin = self.get_argument('_origin', u'') header_origin = self.request.headers.get('Origin') @@ -656,8 +824,9 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): raise ValueError('Uncaught exception') ip, port = self.get_client_addr() - workers = clients.get(ip, {}) - if workers and len(workers) >= options.maxconn: + client_key = self.get_client_key() + workers = clients.get(client_key, {}) + if len(workers) >= options.maxconn: raise tornado.web.HTTPError(403, 'Too many live connections.') self.check_origin() @@ -676,8 +845,9 @@ class IndexHandler(MixinHandler, tornado.web.RequestHandler): self.result.update(status=str(exc)) else: if not workers: - clients[ip] = workers + clients[client_key] = workers worker.src_addr = (ip, port) + worker.client_key = client_key workers[worker.id] = worker self.loop.call_later(options.delay, recycle_worker, worker) self.result.update(id=worker.id, encoding=worker.encoding) @@ -713,7 +883,7 @@ class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler): self.src_addr = self.get_client_addr() logging.info('Connected from {}:{}'.format(*self.src_addr)) - workers = clients.get(self.src_addr[0]) + workers = clients.get(self.get_client_key()) if not workers: self.close(reason='Websocket authentication failed.') return diff --git a/webssh/main.py b/webssh/main.py index 4ef4d94..261df4d 100644 --- a/webssh/main.py +++ b/webssh/main.py @@ -47,6 +47,21 @@ def app_listen(app, port, address, server_settings): ) +def check_trusted_downstream(options, server_settings): + if not options.xheaders: + return + + if server_settings.get('trusted_downstream'): + return + + logging.warning( + 'xheaders is enabled without --tdstream, so any client can spoof ' + 'X-Forwarded-For and impersonate another address. Set ' + '--tdstream= when running behind a reverse proxy, or ' + '--xheaders=False when clients connect directly.' + ) + + def main(): options.parse_command_line() check_encoding_setting(options.encoding) @@ -54,6 +69,7 @@ def main(): app = make_app(make_handlers(loop, options), get_app_settings(options)) ssl_ctx = get_ssl_context(options) server_settings = get_server_settings(options) + check_trusted_downstream(options, server_settings) app_listen(app, options.port, options.address, server_settings) if ssl_ctx: server_settings.update(ssl_options=ssl_ctx) diff --git a/webssh/settings.py b/webssh/settings.py index f03b4b3..41c3883 100644 --- a/webssh/settings.py +++ b/webssh/settings.py @@ -86,6 +86,8 @@ class Font(object): def __init__(self, filename, dirs): self.family = self.get_family(filename) self.url = self.get_url(filename, dirs) + # path relative to static_path, for static_url() in templates + self.path = self.get_url(filename, dirs[1:]) def get_family(self, filename): return filename.split('.')[0] @@ -96,6 +98,7 @@ class Font(object): def get_app_settings(options): auth_manager = get_auth_manager(options) + secret = get_cookie_secret(options) settings = dict( template_path=os.path.join(base_dir, 'webssh', 'templates'), static_path=os.path.join(base_dir, 'webssh', 'static'), @@ -105,7 +108,7 @@ def get_app_settings(options): login_url='/login', auth_manager=auth_manager, auth_session_days=options.auth_session_days, - connection_store=get_connection_store(options), + connection_store=get_connection_store(options, secret), font=Font( get_font_filename(options.font, os.path.join(base_dir, *font_dirs)), @@ -114,7 +117,7 @@ def get_app_settings(options): origin_policy=get_origin_setting(options) ) if auth_manager: - settings['cookie_secret'] = get_cookie_secret(options) + settings['cookie_secret'] = secret return settings @@ -241,11 +244,19 @@ def get_auth_manager(options): ) -def get_connection_store(options): +def get_connection_store(options, secret=''): filename = os.path.join( os.path.expanduser(options.data_dir), 'connections.json' ) - return ConnectionStore(filename) + store = ConnectionStore(filename, secret) + encrypted = store.encrypt_plaintext_passwords() + if encrypted: + logging.info( + 'Encrypted {} plaintext password(s) in {}'.format( + encrypted, filename + ) + ) + return store def get_cookie_secret(options): diff --git a/webssh/static/css/app.css b/webssh/static/css/app.css index ec9d5e6..f538a3a 100644 --- a/webssh/static/css/app.css +++ b/webssh/static/css/app.css @@ -164,6 +164,14 @@ a:hover { margin-bottom: 0; } +.field-hint { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + .inline-toggle { display: inline-flex !important; gap: 6px; @@ -326,6 +334,81 @@ input[type="file"].form-control { height: 100%; } +body.quickbar-open #terminal.terminal-fullscreen { + bottom: 46px; + bottom: calc(46px + env(safe-area-inset-bottom)); +} + +.quickbar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 300; + display: flex; + gap: 6px; + align-items: center; + padding: 6px 10px; + padding-bottom: calc(6px + env(safe-area-inset-bottom)); + overflow-x: auto; + background: #101a23; + border-top: 1px solid rgba(255, 255, 255, 0.08); + -webkit-overflow-scrolling: touch; +} + +.quickbar-btn { + flex: 0 0 auto; + min-width: 42px; + min-height: 34px; + padding: 4px 12px; + color: #d9f1ee; + font-size: 13px; + font-family: SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + background: #1d2b36; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + touch-action: manipulation; +} + +.quickbar-btn:hover { + background: #273946; +} + +.quickbar-btn:active { + color: #0f1720; + background: #55c7ba; + border-color: #55c7ba; +} + +.quickbar-sep { + flex: 0 0 auto; + align-self: center; + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.16); +} + +.terminal-toast { + position: fixed; + top: 14px; + left: 50%; + z-index: 310; + padding: 7px 16px; + color: #eaf7f5; + font-size: 13px; + background: rgba(16, 26, 35, 0.92); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + opacity: 0; + pointer-events: none; + transform: translateX(-50%); + transition: opacity 0.2s ease; +} + +.terminal-toast.visible { + opacity: 1; +} + #waiter { position: fixed; inset: 0; diff --git a/webssh/static/js/main.js b/webssh/static/js/main.js index ce21932..e4819bd 100644 --- a/webssh/static/js/main.js +++ b/webssh/static/js/main.js @@ -45,6 +45,9 @@ jQuery(function($){ delete_connection_button = $('#delete-connection'), show_password_checkbox = $('#show-password'), term_type = $('#term'), + quickbar = $('#quickbar'), + terminal_toast = $('#terminal-toast'), + toast_timer, style = {}, default_title = 'WebSSH', title_element = document.querySelector('title'), @@ -59,8 +62,9 @@ jQuery(function($){ state = DISCONNECTED, messages = {1: '正在连接,请稍候 ...', 2: '当前客户端已经连接。'}, key_max_size = 16384, + directory_max_size = 1024, fields = ['hostname', 'port', 'username'], - form_keys = fields.concat(['password', 'totp']), + form_keys = fields.concat(['password', 'totp', 'directory']), opts_keys = ['bgcolor', 'title', 'encoding', 'command', 'term', 'fontsize', 'fontcolor', 'cursor'], url_form_data = {}, url_opts_data = {}, @@ -193,21 +197,36 @@ jQuery(function($){ function apply_saved_connection() { var profile = selected_connection(); if (!profile) { + apply_saved_password_hint(false); return; } $('#hostname').val(profile.hostname); $('#port').val(profile.port); $('#username').val(profile.username); - $('#password').val(profile.password || ''); $('#privatekey').val(''); $('#passphrase').val(''); $('#totp').val(''); + $('#directory').val(profile.directory || ''); term_type.val(profile.term || 'xterm-256color'); + apply_saved_password_hint(profile.has_password); status.text(''); } + function apply_saved_password_hint(has_password) { + // the server never sends saved passwords to the browser; an empty + // field just tells it to use the one it already has on disk + var password = $('#password'); + + password.val(''); + password.attr( + 'placeholder', + has_password ? '已保存密码,留空即可直接连接' : '' + ); + } + + function delete_saved_connection() { var profile = selected_connection(); if (!profile) { @@ -407,6 +426,56 @@ jQuery(function($){ } + function show_terminal_toast(text) { + terminal_toast.text(text).addClass('visible'); + window.clearTimeout(toast_timer); + toast_timer = window.setTimeout(function() { + terminal_toast.removeClass('visible'); + }, 1500); + } + + + function fallback_copy_text(text) { + var textarea = document.createElement('textarea'), + copied = false; + + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + try { + copied = document.execCommand('copy'); + } catch (e) { + console.error(e); + } + document.body.removeChild(textarea); + return copied; + } + + + function copy_text_to_clipboard(text, callback) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then( + function() { callback(true); }, + function() { callback(fallback_copy_text(text)); } + ); + } else { + callback(fallback_copy_text(text)); + } + } + + + function read_clipboard_text(on_text, on_error) { + if (navigator.clipboard && navigator.clipboard.readText) { + navigator.clipboard.readText().then(on_text, on_error); + } else { + on_error(); + } + } + + function read_as_text_with_decoder(file, callback, decoder) { var reader = new window.FileReader(); @@ -486,6 +555,8 @@ jQuery(function($){ current_terminal = undefined; } $('#terminal').removeClass('terminal-fullscreen').empty(); + quickbar.hide(); + $('body').removeClass('quickbar-open'); } @@ -575,6 +646,9 @@ jQuery(function($){ if (text.indexOf('Invalid private key: ') === 0) { return '私钥文件无效:' + text.slice('Invalid private key: '.length); } + if (text.indexOf('Invalid directory: ') === 0) { + return '初始目录无效:' + text.slice('Invalid directory: '.length); + } return text; } @@ -770,8 +844,112 @@ jQuery(function($){ sock.send(JSON.stringify({'data': data})); }); + function send_data(data) { + if (sock && sock.readyState === window.WebSocket.OPEN) { + sock.send(JSON.stringify({'data': data})); + } + } + + function copy_terminal_selection() { + if (!term || !term.hasSelection()) { + return false; + } + + copy_text_to_clipboard(term.getSelection(), function(copied) { + show_terminal_toast(copied ? '已复制' : '复制失败'); + }); + term.clearSelection(); + return true; + } + + function paste_into_terminal() { + read_clipboard_text( + function(text) { + if (text) { + send_data(text); + } + }, + function() { + show_terminal_toast('无法读取剪贴板,请使用 Ctrl+V 粘贴'); + } + ); + } + + term.attachCustomKeyEventHandler(function(e) { + if (e.type !== 'keydown') { + return true; + } + + var key = e.key ? e.key.toLowerCase() : ''; + + // Ctrl+Shift+C / Ctrl+Shift+V: copy and paste shortcuts + if (e.ctrlKey && e.shiftKey && key === 'c') { + e.preventDefault(); + copy_terminal_selection(); + return false; + } + if (e.ctrlKey && e.shiftKey && key === 'v') { + e.preventDefault(); + paste_into_terminal(); + return false; + } + // Ctrl+C with an active selection copies instead of sending SIGINT + if (e.ctrlKey && !e.shiftKey && !e.altKey && key === 'c' && + term.hasSelection()) { + e.preventDefault(); + copy_terminal_selection(); + return false; + } + return true; + }); + + // Right click: copy the selection if there is one, otherwise paste + $('#terminal').off('contextmenu.wssh').on('contextmenu.wssh', function(e) { + if (!term) { + return; + } + e.preventDefault(); + if (term.hasSelection()) { + copy_terminal_selection(); + } else { + paste_into_terminal(); + } + }); + + // Keep terminal focus (and selection) when pressing quickbar buttons + quickbar.off('mousedown.wssh').on('mousedown.wssh', '.quickbar-btn', + function(e) { + e.preventDefault(); + } + ); + + quickbar.off('click.wssh').on('click.wssh', '.quickbar-btn', function(e) { + var btn = $(this), + action = btn.attr('data-action'), + key = btn.attr('data-key'), + named_keys = {'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'}; + + e.preventDefault(); + + if (action === 'copy') { + if (!copy_terminal_selection()) { + show_terminal_toast('请先选中要复制的内容'); + } + } else if (action === 'paste') { + paste_into_terminal(); + } else if (key) { + send_data(named_keys[key] || key); + } + + if (term) { + term.focus(); + } + }); + sock.onopen = function() { term.open(terminal); + quickbar.show(); + $('body').addClass('quickbar-open'); toggle_fullscreen(term); update_font_family(term); term.focus(); @@ -796,6 +974,8 @@ jQuery(function($){ var reason = write_disconnect_message(term, e.reason); $('#terminal').removeClass('terminal-fullscreen'); + quickbar.hide(); + $('body').removeClass('quickbar-open'); sock = undefined; reset_wssh(); log_status(reason, true); @@ -805,7 +985,9 @@ jQuery(function($){ title_element.text = default_title; }; - $(window).resize(function(){ + // namespaced so reconnecting replaces the handler instead of stacking + // another one on top of it + $(window).off('resize.wssh').on('resize.wssh', function(){ if (term) { resize_terminal(term); } @@ -849,6 +1031,7 @@ jQuery(function($){ port = data.get('port'), username = data.get('username'), pk = data.get('privatekey'), + directory = data.get('directory'), result = { valid: false, data: data, @@ -883,6 +1066,13 @@ jQuery(function($){ } } + if (directory) { + if (directory.length > directory_max_size || + /[\x00-\x1f\x7f]/.test(directory)) { + errors.push('初始目录无效:' + directory); + } + } + if (!errors.length || debug) { result.valid = true; result.title = username + '@' + hostname + ':' + port; @@ -995,7 +1185,7 @@ jQuery(function($){ } - function connect(hostname, port, username, password, privatekey, passphrase, totp) { + function connect(hostname, port, username, password, privatekey, passphrase, totp, directory) { // for console use var result, opts; @@ -1015,7 +1205,8 @@ jQuery(function($){ password: password, privatekey: privatekey, passphrase: passphrase, - totp: totp + totp: totp, + directory: directory }; } else { opts = hostname; @@ -1042,7 +1233,11 @@ jQuery(function($){ }); $(form_id).on('reset', function() { - window.setTimeout(update_password_visibility, 0); + window.setTimeout(function() { + update_password_visibility(); + saved_connections_select.val(''); + apply_saved_password_hint(false); + }, 0); }); load_connection_button.click(function() { diff --git a/webssh/storage.py b/webssh/storage.py index 9910f24..0ce597a 100644 --- a/webssh/storage.py +++ b/webssh/storage.py @@ -4,20 +4,27 @@ import os import threading import time +from webssh.crypto import SecretBox, is_encrypted + DEFAULT_PORT = 22 +# keys that must never be handed out to a browser +PRIVATE_FIELDS = ('password', 'password_enc') + class ConnectionStore(object): - def __init__(self, filename): + def __init__(self, filename, secret=''): self.filename = filename self.lock = threading.Lock() + self.box = SecretBox(secret) if secret else None def list(self, owner): data = self._read() profiles = [ - profile for profile in data.get('connections', []) + self._public(profile) + for profile in data.get('connections', []) if profile.get('owner') == owner ] return sorted( @@ -26,6 +33,24 @@ class ConnectionStore(object): reverse=True ) + def get_password(self, owner, profile_id): + """Return the stored password, never exposed through list().""" + for profile in self._read().get('connections', []): + if profile.get('owner') != owner: + continue + if profile.get('id') != profile_id: + continue + + encrypted = profile.get('password_enc') + if encrypted: + return self.box.decrypt(encrypted) if self.box else '' + # written before the store started encrypting + return profile.get('password') or '' + return '' + + def make_id(self, owner, hostname, port, username): + return self._make_id(owner, hostname, port, username) + def upsert(self, owner, connection): profile = self._normalize(owner, connection) @@ -51,7 +76,7 @@ class ConnectionStore(object): self._write(data) - return profile + return self._public(profile) def delete(self, owner, profile_id): with self.lock: @@ -69,10 +94,40 @@ class ConnectionStore(object): self._write(data) return deleted + def encrypt_plaintext_passwords(self): + """Upgrade files written before passwords were encrypted at rest.""" + if not self.box: + return 0 + + with self.lock: + data = self._read() + changed = 0 + for profile in data.get('connections', []): + password = profile.pop('password', '') + if not password or is_encrypted(profile.get('password_enc')): + continue + profile['password_enc'] = self.box.encrypt(password) + changed += 1 + + if changed: + self._write(data) + return changed + + def _public(self, profile): + public = { + key: value for key, value in profile.items() + if key not in PRIVATE_FIELDS + } + public['has_password'] = bool( + profile.get('password_enc') or profile.get('password') + ) + return public + def _normalize(self, owner, connection): hostname = (connection.get('hostname') or '').strip() username = (connection.get('username') or '').strip() term = (connection.get('term') or 'xterm-256color').strip() + directory = (connection.get('directory') or '').strip() port = connection.get('port') or DEFAULT_PORT port = int(port) password = connection.get('password') @@ -82,17 +137,25 @@ class ConnectionStore(object): auth_type = connection.get('auth_type') or 'password' profile_id = self._make_id(owner, hostname, port, username) - return { + profile = { 'id': profile_id, 'owner': owner, 'title': title, 'hostname': hostname, 'port': port, 'username': username, - 'password': password, 'term': term, + 'directory': directory, 'auth_type': auth_type } + if password: + if self.box: + profile['password_enc'] = self.box.encrypt(password) + else: + # no server secret to encrypt with; keep the legacy layout so + # encrypt_plaintext_passwords() can upgrade it later + profile['password'] = password + return profile def _make_id(self, owner, hostname, port, username): raw = '\0'.join([owner, hostname, str(port), username]) diff --git a/webssh/templates/index.html b/webssh/templates/index.html index 861200a..7a22ee0 100644 --- a/webssh/templates/index.html +++ b/webssh/templates/index.html @@ -4,16 +4,16 @@ WebSSH - - - - - + + + + + {% if font.family %}