Session security

This commit is contained in:
Florian Mounier
2015-10-02 17:24:25 +02:00
parent 5aa697381a
commit 6c5cbeaca5
4 changed files with 137 additions and 77 deletions

View File

@@ -65,46 +65,47 @@ tornado.options.define("theme", default=None,
if os.getuid() == 0: if os.getuid() == 0:
conf_file = os.path.join( conf_file = os.path.join(
os.path.abspath(os.sep), 'etc', 'butterfly', 'butterfly.conf') os.path.abspath(os.sep), 'etc', 'butterfly', 'butterfly.conf')
ssl_dir = os.path.join(os.path.abspath(os.sep), 'etc', 'butterfly', 'ssl')
else: else:
conf_file = os.path.join( conf_file = os.path.join(
os.path.expanduser('~'), '.butterfly', 'butterfly.conf') os.path.expanduser('~'), '.butterfly', 'butterfly.conf')
ssl_dir = os.path.join(os.path.expanduser('~'), '.butterfly', 'ssl')
tornado.options.define("conf", default=conf_file, tornado.options.define("conf", default=conf_file,
help="Butterfly configuration file. " help="Butterfly configuration file. "
"Contains the same options as command line.") "Contains the same options as command line.")
if os.path.exists(conf_file): tornado.options.define("ssl_dir", default=ssl_dir,
tornado.options.parse_config_file(conf_file) help="Force SSL directory location")
tornado.options.parse_command_line() tornado.options.parse_command_line()
if os.path.exists(tornado.options.options.conf):
tornado.options.parse_config_file(tornado.options.options.conf)
options = tornado.options.options
for logger in ('tornado.access', 'tornado.application', for logger in ('tornado.access', 'tornado.application',
'tornado.general', 'butterfly'): 'tornado.general', 'butterfly'):
level = logging.WARNING level = logging.WARNING
if tornado.options.options.debug: if options.debug:
level = logging.INFO level = logging.INFO
if tornado.options.options.more: if options.more:
level = logging.DEBUG level = logging.DEBUG
logging.getLogger(logger).setLevel(level) logging.getLogger(logger).setLevel(level)
log = logging.getLogger('butterfly') log = logging.getLogger('butterfly')
log.info('Starting server') log.info('Starting server')
host = tornado.options.options.host host = options.host
port = tornado.options.options.port port = options.port
if os.getuid() == 0: if not os.path.exists(options.ssl_dir):
ssl_dir = os.path.join(os.path.abspath(os.sep), 'etc', 'butterfly', 'ssl') os.makedirs(options.ssl_dir)
else:
ssl_dir = os.path.join(os.path.expanduser('~'), '.butterfly', 'ssl')
if not os.path.exists(ssl_dir):
os.makedirs(ssl_dir)
def to_abs(file): def to_abs(file):
return os.path.join(ssl_dir, file) return os.path.join(options.ssl_dir, file)
ca, ca_key, cert, cert_key, pkcs12 = map(to_abs, [ ca, ca_key, cert, cert_key, pkcs12 = map(to_abs, [
'butterfly_ca.crt', 'butterfly_ca.key', 'butterfly_ca.crt', 'butterfly_ca.key',
@@ -131,7 +132,7 @@ def read(file):
with open(file, 'rb') as fd: with open(file, 'rb') as fd:
return fd.read() return fd.read()
if tornado.options.options.generate_certs: if options.generate_certs:
from OpenSSL import crypto from OpenSSL import crypto
print('Generating certificates for %s (change it with --host)\n' % host) print('Generating certificates for %s (change it with --host)\n' % host)
@@ -180,8 +181,8 @@ if tornado.options.options.generate_certs:
sys.exit(0) sys.exit(0)
if (tornado.options.options.generate_current_user_pkcs or if (options.generate_current_user_pkcs or
tornado.options.options.generate_user_pkcs): options.generate_user_pkcs):
from butterfly import utils from butterfly import utils
try: try:
current_user = utils.User() current_user = utils.User()
@@ -193,10 +194,10 @@ if (tornado.options.options.generate_current_user_pkcs or
print('Please generate certificates using --generate-certs before') print('Please generate certificates using --generate-certs before')
sys.exit(1) sys.exit(1)
if tornado.options.options.generate_current_user_pkcs: if options.generate_current_user_pkcs:
user = current_user.name user = current_user.name
else: else:
user = tornado.options.options.generate_user_pkcs user = options.generate_user_pkcs
if user != current_user.name and current_user.uid != 0: if user != current_user.name and current_user.uid != 0:
print('Cannot create certificate for another user with ' print('Cannot create certificate for another user with '
@@ -239,7 +240,7 @@ if (tornado.options.options.generate_current_user_pkcs or
sys.exit(0) sys.exit(0)
if tornado.options.options.unsecure: if options.unsecure:
ssl_opts = None ssl_opts = None
else: else:
if not all(map(os.path.exists, [cert % host, cert_key % host, ca])): if not all(map(os.path.exists, [cert % host, cert_key % host, ca])):
@@ -260,15 +261,15 @@ else:
'ca_certs': ca, 'ca_certs': ca,
'cert_reqs': ssl.CERT_REQUIRED 'cert_reqs': ssl.CERT_REQUIRED
} }
if tornado.options.options.ssl_version is not None: if options.ssl_version is not None:
if not hasattr( if not hasattr(
ssl, 'PROTOCOL_%s' % tornado.options.options.ssl_version): ssl, 'PROTOCOL_%s' % options.ssl_version):
print( print(
"Unknown SSL protocol %s" % "Unknown SSL protocol %s" %
tornado.options.options.ssl_version) options.ssl_version)
sys.exit(1) sys.exit(1)
ssl_opts['ssl_version'] = getattr( ssl_opts['ssl_version'] = getattr(
ssl, 'PROTOCOL_%s' % tornado.options.options.ssl_version) ssl, 'PROTOCOL_%s' % options.ssl_version)
from butterfly import application from butterfly import application
@@ -285,6 +286,6 @@ log.info('Starting loop')
ioloop = tornado.ioloop.IOLoop.instance() ioloop = tornado.ioloop.IOLoop.instance()
url = "http%s://%s:%d/" % ( url = "http%s://%s:%d/" % (
"s" if not tornado.options.options.unsecure else "", host, port) "s" if not options.unsecure else "", host, port)
log.warn('Butterfly is ready, open your browser to: %s' % url) log.warn('Butterfly is ready, open your browser to: %s' % url)
ioloop.start() ioloop.start()

View File

@@ -22,6 +22,7 @@ import tornado.options
import tornado.process import tornado.process
import tornado.web import tornado.web
import tornado.websocket import tornado.websocket
from collections import defaultdict
from butterfly import url, Route, utils, __version__ from butterfly import url, Route, utils, __version__
from butterfly.terminal import Terminal from butterfly.terminal import Terminal
@@ -98,11 +99,13 @@ class Font(Route):
'(?:/wd/(?P<path>.+))?') '(?:/wd/(?P<path>.+))?')
class TermWebSocket(Route, tornado.websocket.WebSocketHandler): class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
session_history_size = 10000 session_history_size = 10000
# List of websockets per session # List of websockets per session per user
sessions = {} # dict: user -> dict: session -> [TermWebSocket]
sessions = defaultdict(dict)
# Terminal for session # Terminal for session per user
terminals = {} # dict: user -> dict: session -> Terminal
terminals = defaultdict(dict)
# All terminals sockets for systemd socket deactivation # All terminals sockets for systemd socket deactivation
sockets = [] sockets = []
@@ -113,6 +116,7 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
def open(self, user, path, session): def open(self, user, path, session):
self.session = session self.session = session
self.closed = False self.closed = False
self.secure_user = None
# Prevent cross domain # Prevent cross domain
if self.request.headers['Origin'] not in ( if self.request.headers['Origin'] not in (
@@ -130,46 +134,93 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
self.log.info('Websocket opened %r' % self) self.log.info('Websocket opened %r' % self)
self.set_nodelay(True) self.set_nodelay(True)
socket = utils.Socket(self.ws_connection.stream.socket)
opts = tornado.options.options
if not opts.unsecure:
user = utils.parse_cert(
self.ws_connection.stream.socket.getpeercert())
assert user, 'No user in certificate'
try:
user = utils.User(name=user)
except LookupError:
raise Exception('Invalid user in certificate')
# Certificate authed user
self.secure_user = user
elif socket.local and socket.user == utils.User():
# Local to local returning browser user
self.secure_user = socket.user
# Handling terminal session # Handling terminal session
if session: if session:
if session in TermWebSocket.sessions: if session in self.user_sessions:
# Session already here, registering websocket # Session already here, registering websocket
TermWebSocket.sessions[session].append(self) self.user_sessions[session].append(self)
self.write_message(TermWebSocket.history[session]) self.write_message(TermWebSocket.history[session])
# And returning, we don't want another terminal # And returning, we don't want another terminal
return return
else: else:
# New session, opening terminal # New session, opening terminal
TermWebSocket.sessions[session] = [self] self.user_sessions[session] = [self]
TermWebSocket.history[session] = '' TermWebSocket.history[session] = ''
terminal = Terminal( terminal = Terminal(
user, path, session, user, path, session, socket,
self.ws_connection.stream.socket, self.request.headers['Host'], self.render_string, self.write)
self.request.headers['Host'],
self.render_string, terminal.pty()
self.write)
if session: if session:
TermWebSocket.terminals[session] = terminal if not self.secure_user:
self.log.error(
'No terminal session without secure authenticated user'
'or local user.')
self._terminal = terminal
self.session = None
else:
self.log.info('Openning session %s for secure user %r' % (
session, self.secure_user))
self.user_terminals[session] = terminal
else: else:
self._terminal = terminal self._terminal = terminal
@classmethod @property
def close_all(cls, session): def user_sessions(self):
for inst in TermWebSocket.sessions[session][:]: """Return the dict session of socket lists"""
inst.on_close() if not self.secure_user:
inst.close() return {}
del TermWebSocket.sessions[session] return TermWebSocket.sessions[self.secure_user.name]
del TermWebSocket.terminals[session]
@property
def user_terminals(self):
"""Return the dict session of terminal"""
if not self.secure_user:
return {}
return TermWebSocket.terminals[self.secure_user.name]
@classmethod @classmethod
def broadcast(cls, session, message): def close_all(cls, session, user):
sessions = TermWebSocket.sessions.get(user.name)
if sessions:
sockets = sessions[session]
for socket in sockets[:]:
socket.on_close()
socket.close()
del sessions[session]
terminals = TermWebSocket.terminals.get(user.name)
del terminals[session]
@classmethod
def broadcast(cls, session, message, user):
cls.history[session] += message cls.history[session] += message
if len(cls.history) > cls.session_history_size: if len(cls.history) > cls.session_history_size:
cls.history[session] = cls.history[session][ cls.history[session] = cls.history[session][
-cls.session_history_size:] -cls.session_history_size:]
for session in cls.sessions[session][:]: sessions = cls.sessions.get(user.name, [])
for session in sessions[session]:
try: try:
session.write_message(message) session.write_message(message)
except Exception: except Exception:
@@ -178,9 +229,10 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
def write(self, message): def write(self, message):
if self.session: if self.session:
if message is None: if message is None:
TermWebSocket.close_all(self.session) TermWebSocket.close_all(self.session, self.secure_user)
else: else:
TermWebSocket.broadcast(self.session, message) TermWebSocket.broadcast(
self.session, message, self.secure_user)
else: else:
if message is None: if message is None:
self.on_close() self.on_close()
@@ -189,8 +241,8 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
self.write_message(message) self.write_message(message)
def on_message(self, message): def on_message(self, message):
if self.session: if self.session and self.secure_user:
term = TermWebSocket.terminals.get(self.session) term = self.user_terminals.get(self.session)
term and term.write(message) term and term.write(message)
else: else:
self._terminal.write(message) self._terminal.write(message)
@@ -202,7 +254,7 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
self.log.info('Websocket closed %r' % self) self.log.info('Websocket closed %r' % self)
TermWebSocket.sockets.remove(self) TermWebSocket.sockets.remove(self)
if self.session: if self.session:
TermWebSocket.sessions[self.session].remove(self) self.user_sessions[self.session].remove(self)
elif hasattr(self, '_terminal'): elif hasattr(self, '_terminal'):
self._terminal.close() self._terminal.close()
else: else:
@@ -212,10 +264,18 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler):
sys.exit(0) sys.exit(0)
@url(r'/list(?:user/(.+))?/?(?:wd/(.+))?') @url(r'/sessions')
class List(Route): class Sessions(Route):
"""List available terminals""" """List available sessions"""
def get(self, user, path): def get(self):
if not tornado.options.options.unsecure and user: if tornado.options.options.unsecure:
raise tornado.web.HTTPError(400) raise tornado.web.HTTPError(403)
return self.render('list.html', sessions=TermWebSocket.sessions)
cert = self.request.get_ssl_certificate()
user = utils.parse_cert(cert)
if not user:
raise tornado.web.HTTPError(403)
return self.render(
'list.html', sessions=TermWebSocket.sessions.get(user, []))

View File

@@ -10,15 +10,15 @@
<link rel="shortcut icon" href="{{ static_url('images/favicon.png') }}"> <link rel="shortcut icon" href="{{ static_url('images/favicon.png') }}">
<title>Butterfly</title> <title>Butterfly</title>
{# <link href="/style.css" rel="stylesheet"> #} <link href="/style.css" rel="stylesheet">
</head> </head>
<body> <body>
<p>Currently open butterfly sessions : <h1>Currently open butterfly sessions :</h1>
<ul> <ul>
{% for session in sessions %} {% for session in sessions %}
<li> <a target="_blank" href="/session/{{ session }}">{{ session }}</a></li> <li><h2><a target="_blank" href="/session/{{ session }}">{{ session }}</a></h2></li>
{% end %} {% end %}
</ul> </ul>
</body> </body>
</html> </html>

View File

@@ -53,7 +53,7 @@ class Terminal(object):
self.send = send self.send = send
self.fd = None self.fd = None
self.closed = False self.closed = False
self.socket = utils.Socket(socket) self.socket = socket
log.info('Terminal opening with session: %s and socket %r' % ( log.info('Terminal opening with session: %s and socket %r' % (
self.session, self.socket)) self.session, self.socket))
self.path = path self.path = path
@@ -77,15 +77,10 @@ class Terminal(object):
# user as the one who opened the socket ie: the one # user as the one who opened the socket ie: the one
# openning a terminal in browser # openning a terminal in browser
if not self.callee and not self.user and self.socket.local: if not self.callee and not self.user and self.socket.local:
self.callee = self.caller self.user = self.callee = self.caller
else: else:
user = utils.parse_cert(socket.getpeercert()) # Authed user
assert user, 'No user in certificate' self.callee = self.user
self.user = user
try:
self.callee = utils.User(name=self.user)
except LookupError:
raise Exception('Invalid user in certificate')
if tornado.options.options.motd != '': if tornado.options.options.motd != '':
motd = (render_string( motd = (render_string(
@@ -99,7 +94,7 @@ class Terminal(object):
.replace('\n', '\r\n')) .replace('\n', '\r\n'))
self.send(motd) self.send(motd)
self.pty() log.info('Forking pty for user %r' % self.user)
def pty(self): def pty(self):
# Make a "unique" id in 4 bytes # Make a "unique" id in 4 bytes
@@ -112,6 +107,8 @@ class Terminal(object):
self.pid, self.fd = pty.fork() self.pid, self.fd = pty.fork()
if self.pid == 0: if self.pid == 0:
self.determine_user() self.determine_user()
log.debug('Pty forked for user %r caller %r callee %r' % (
self.user, self.caller, self.callee))
self.shell() self.shell()
else: else:
self.communicate() self.communicate()
@@ -126,7 +123,7 @@ class Terminal(object):
try: try:
user = input('login: ') user = input('login: ')
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
log.debug("Errorin login input", exc_info=True) log.debug("Error in login input", exc_info=True)
pass pass
try: try:
@@ -201,6 +198,8 @@ class Terminal(object):
os.initgroups(self.callee.name, self.callee.gid) os.initgroups(self.callee.name, self.callee.gid)
os.setgid(self.callee.gid) os.setgid(self.callee.gid)
os.setuid(self.callee.uid) os.setuid(self.callee.uid)
# Apparently necessary for some cmd
env['LOGNAME'] = env['USER'] = self.callee.name
except Exception: except Exception:
log.error( log.error(
'The server must be run as root ' 'The server must be run as root '