c013a389fe
- 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.
165 lines
6.3 KiB
Python
165 lines
6.3 KiB
Python
import json
|
|
import shutil
|
|
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
|
|
|
|
|
|
class TestAppAuth(AsyncHTTPTestCase):
|
|
|
|
def setUp(self):
|
|
self.tmpdir = tempfile.mkdtemp()
|
|
super(TestAppAuth, self).setUp()
|
|
|
|
def tearDown(self):
|
|
super(TestAppAuth, self).tearDown()
|
|
shutil.rmtree(self.tmpdir)
|
|
|
|
def get_app(self):
|
|
loop = self.io_loop
|
|
options.auth = True
|
|
options.auth_username = 'admin'
|
|
options.auth_password = ''
|
|
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'
|
|
handlers = make_handlers(loop, options)
|
|
return make_app(handlers, get_app_settings(options))
|
|
|
|
def get_cookie_header(self, response):
|
|
cookie = response.headers.get_list('Set-Cookie')[0]
|
|
return {'Cookie': cookie.split(';', 1)[0]}
|
|
|
|
def test_auth_setup_and_saved_connections_endpoint(self):
|
|
response = self.fetch('/', follow_redirects=False)
|
|
self.assertEqual(response.code, 302)
|
|
self.assertIn('/login', response.headers['Location'])
|
|
|
|
response = self.fetch('/login')
|
|
self.assertIn('创建管理员账号'.encode('utf-8'), response.body)
|
|
|
|
body = 'username=admin&password=secret&confirm=secret'
|
|
response = self.fetch('/login', method='POST', body=body,
|
|
follow_redirects=False)
|
|
self.assertEqual(response.code, 302)
|
|
headers = self.get_cookie_header(response)
|
|
|
|
response = self.fetch('/', headers=headers)
|
|
self.assertIn('已保存连接'.encode('utf-8'), response.body)
|
|
|
|
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)
|