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:
+4
-1
@@ -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"]
|
||||
|
||||
@@ -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 `<data-dir>/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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+243
-5
@@ -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)
|
||||
|
||||
+150
-4
@@ -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))
|
||||
|
||||
+38
-1
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
+180
-10
@@ -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
|
||||
|
||||
@@ -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=<proxy ip> 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)
|
||||
|
||||
+15
-4
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
+201
-6
@@ -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() {
|
||||
|
||||
+68
-5
@@ -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])
|
||||
|
||||
+39
-12
@@ -4,16 +4,16 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WebSSH</title>
|
||||
<link href="static/img/favicon.png" rel="icon" type="image/png">
|
||||
<link href="static/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="static/css/xterm.min.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="static/css/fullscreen.min.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="static/css/app.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="{{ static_url('img/favicon.png') }}" rel="icon" type="image/png">
|
||||
<link href="{{ static_url('css/bootstrap.min.css') }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{{ static_url('css/xterm.min.css') }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{{ static_url('css/fullscreen.min.css') }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{{ static_url('css/app.css') }}" rel="stylesheet" type="text/css"/>
|
||||
{% if font.family %}
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: '{{ font.family }}';
|
||||
src: url('{{ font.url }}');
|
||||
src: url('{{ static_url(font.path) }}');
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -106,6 +106,14 @@
|
||||
<input class="form-control" type="password" id="totp"
|
||||
name="totp" value="">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="directory">初始目录</label>
|
||||
<input class="form-control" type="text" id="directory"
|
||||
name="directory" value="" placeholder="/var/www"
|
||||
autocomplete="off" autocapitalize="off"
|
||||
autocorrect="off" spellcheck="false">
|
||||
<small class="field-hint">登录后自动 cd 到该目录,留空则不切换</small>
|
||||
</div>
|
||||
<div class="field save-field">
|
||||
<label class="save-toggle" for="save">
|
||||
<input type="checkbox" id="save" name="save" value="1" checked>
|
||||
@@ -129,11 +137,30 @@
|
||||
<div id="terminal"></div>
|
||||
</section>
|
||||
|
||||
<script src="static/js/jquery.min.js"></script>
|
||||
<script src="static/js/popper.min.js"></script>
|
||||
<script src="static/js/bootstrap.min.js"></script>
|
||||
<script src="static/js/xterm.min.js"></script>
|
||||
<script src="static/js/xterm-addon-fit.min.js"></script>
|
||||
<script src="static/js/main.js"></script>
|
||||
<div id="terminal-toast" class="terminal-toast" role="status"></div>
|
||||
|
||||
<div id="quickbar" class="quickbar" style="display: none">
|
||||
<button type="button" class="quickbar-btn" data-action="copy">复制</button>
|
||||
<button type="button" class="quickbar-btn" data-action="paste">粘贴</button>
|
||||
<span class="quickbar-sep" aria-hidden="true"></span>
|
||||
<button type="button" class="quickbar-btn" data-key="ctrl-c">Ctrl+C</button>
|
||||
<button type="button" class="quickbar-btn" data-key="tab">Tab</button>
|
||||
<button type="button" class="quickbar-btn" data-key="esc">Esc</button>
|
||||
<span class="quickbar-sep" aria-hidden="true"></span>
|
||||
<button type="button" class="quickbar-btn" data-key="-">-</button>
|
||||
<button type="button" class="quickbar-btn" data-key="/">/</button>
|
||||
<button type="button" class="quickbar-btn" data-key=".">.</button>
|
||||
<button type="button" class="quickbar-btn" data-key="~">~</button>
|
||||
<button type="button" class="quickbar-btn" data-key="|">|</button>
|
||||
<button type="button" class="quickbar-btn" data-key=":">:</button>
|
||||
<button type="button" class="quickbar-btn" data-key="_">_</button>
|
||||
</div>
|
||||
|
||||
<script src="{{ static_url('js/jquery.min.js') }}"></script>
|
||||
<script src="{{ static_url('js/popper.min.js') }}"></script>
|
||||
<script src="{{ static_url('js/bootstrap.min.js') }}"></script>
|
||||
<script src="{{ static_url('js/xterm.min.js') }}"></script>
|
||||
<script src="{{ static_url('js/xterm-addon-fit.min.js') }}"></script>
|
||||
<script src="{{ static_url('js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WebSSH 登录</title>
|
||||
<link href="static/img/favicon.png" rel="icon" type="image/png">
|
||||
<link href="static/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="static/css/app.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="{{ static_url('img/favicon.png') }}" rel="icon" type="image/png">
|
||||
<link href="{{ static_url('css/bootstrap.min.css') }}" rel="stylesheet" type="text/css"/>
|
||||
<link href="{{ static_url('css/app.css') }}" rel="stylesheet" type="text/css"/>
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<main class="login-shell">
|
||||
|
||||
@@ -14,6 +14,9 @@ except ImportError:
|
||||
|
||||
numeric = re.compile(r'[0-9]+$')
|
||||
allowed = re.compile(r'(?!-)[a-z0-9-]{1,63}(?<!-)$', re.IGNORECASE)
|
||||
control_chars = re.compile(r'[\x00-\x1f\x7f]')
|
||||
|
||||
MAX_DIRECTORY_LENGTH = 1024
|
||||
|
||||
|
||||
def to_str(bstr, encoding='utf-8'):
|
||||
@@ -90,6 +93,21 @@ def is_valid_hostname(hostname):
|
||||
return all(allowed.match(label) for label in labels)
|
||||
|
||||
|
||||
def is_valid_directory(directory):
|
||||
if not directory or len(directory) > MAX_DIRECTORY_LENGTH:
|
||||
return False
|
||||
return not control_chars.search(directory)
|
||||
|
||||
|
||||
def quote_shell_arg(value):
|
||||
"""Wrap a value in single quotes so a POSIX shell treats it literally."""
|
||||
return "'{}'".format(value.replace("'", "'\\''"))
|
||||
|
||||
|
||||
def build_cd_command(directory):
|
||||
return 'cd {}\r'.format(quote_shell_arg(directory))
|
||||
|
||||
|
||||
def is_same_primary_domain(domain1, domain2):
|
||||
i = -1
|
||||
dots = 0
|
||||
|
||||
+9
-8
@@ -12,19 +12,18 @@ from tornado.util import errno_from_exception
|
||||
|
||||
|
||||
BUF_SIZE = 32 * 1024
|
||||
clients = {} # {ip: {id: worker}}
|
||||
clients = {} # {client_key: {id: worker}}
|
||||
|
||||
|
||||
def clear_worker(worker, clients):
|
||||
ip = worker.src_addr[0]
|
||||
workers = clients.get(ip)
|
||||
assert worker.id in workers
|
||||
workers.pop(worker.id)
|
||||
key = worker.client_key
|
||||
workers = clients.get(key)
|
||||
if not workers or worker.id not in workers:
|
||||
return
|
||||
|
||||
workers.pop(worker.id)
|
||||
if not workers:
|
||||
clients.pop(ip)
|
||||
if not clients:
|
||||
clients.clear()
|
||||
clients.pop(key, None)
|
||||
|
||||
|
||||
def recycle_worker(worker):
|
||||
@@ -46,6 +45,8 @@ class Worker(object):
|
||||
self.handler = None
|
||||
self.mode = IOLoop.READ
|
||||
self.closed = False
|
||||
self.src_addr = None
|
||||
self.client_key = None
|
||||
|
||||
def __call__(self, fd, events):
|
||||
if events & IOLoop.READ:
|
||||
|
||||
Reference in New Issue
Block a user