From 6c5cbeaca5a7ccd9ad1c852a27f7d33d532b8775 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Fri, 2 Oct 2015 17:24:25 +0200 Subject: [PATCH] Session security --- butterfly.server.py | 51 +++++++------- butterfly/routes.py | 128 +++++++++++++++++++++++++--------- butterfly/templates/list.html | 14 ++-- butterfly/terminal.py | 21 +++--- 4 files changed, 137 insertions(+), 77 deletions(-) diff --git a/butterfly.server.py b/butterfly.server.py index ab81d1c..3311286 100755 --- a/butterfly.server.py +++ b/butterfly.server.py @@ -65,46 +65,47 @@ tornado.options.define("theme", default=None, if os.getuid() == 0: conf_file = os.path.join( os.path.abspath(os.sep), 'etc', 'butterfly', 'butterfly.conf') + ssl_dir = os.path.join(os.path.abspath(os.sep), 'etc', 'butterfly', 'ssl') else: conf_file = os.path.join( os.path.expanduser('~'), '.butterfly', 'butterfly.conf') + ssl_dir = os.path.join(os.path.expanduser('~'), '.butterfly', 'ssl') tornado.options.define("conf", default=conf_file, help="Butterfly configuration file. " "Contains the same options as command line.") -if os.path.exists(conf_file): - tornado.options.parse_config_file(conf_file) +tornado.options.define("ssl_dir", default=ssl_dir, + help="Force SSL directory location") 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', 'tornado.general', 'butterfly'): level = logging.WARNING - if tornado.options.options.debug: + if options.debug: level = logging.INFO - if tornado.options.options.more: + if options.more: level = logging.DEBUG logging.getLogger(logger).setLevel(level) log = logging.getLogger('butterfly') log.info('Starting server') -host = tornado.options.options.host -port = tornado.options.options.port +host = options.host +port = options.port -if os.getuid() == 0: - ssl_dir = os.path.join(os.path.abspath(os.sep), 'etc', 'butterfly', 'ssl') -else: - ssl_dir = os.path.join(os.path.expanduser('~'), '.butterfly', 'ssl') - -if not os.path.exists(ssl_dir): - os.makedirs(ssl_dir) +if not os.path.exists(options.ssl_dir): + os.makedirs(options.ssl_dir) 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, [ 'butterfly_ca.crt', 'butterfly_ca.key', @@ -131,7 +132,7 @@ def read(file): with open(file, 'rb') as fd: return fd.read() -if tornado.options.options.generate_certs: +if options.generate_certs: from OpenSSL import crypto print('Generating certificates for %s (change it with --host)\n' % host) @@ -180,8 +181,8 @@ if tornado.options.options.generate_certs: sys.exit(0) -if (tornado.options.options.generate_current_user_pkcs or - tornado.options.options.generate_user_pkcs): +if (options.generate_current_user_pkcs or + options.generate_user_pkcs): from butterfly import utils try: 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') sys.exit(1) - if tornado.options.options.generate_current_user_pkcs: + if options.generate_current_user_pkcs: user = current_user.name else: - user = tornado.options.options.generate_user_pkcs + user = options.generate_user_pkcs if user != current_user.name and current_user.uid != 0: print('Cannot create certificate for another user with ' @@ -239,7 +240,7 @@ if (tornado.options.options.generate_current_user_pkcs or sys.exit(0) -if tornado.options.options.unsecure: +if options.unsecure: ssl_opts = None else: if not all(map(os.path.exists, [cert % host, cert_key % host, ca])): @@ -260,15 +261,15 @@ else: 'ca_certs': ca, 'cert_reqs': ssl.CERT_REQUIRED } - if tornado.options.options.ssl_version is not None: + if options.ssl_version is not None: if not hasattr( - ssl, 'PROTOCOL_%s' % tornado.options.options.ssl_version): + ssl, 'PROTOCOL_%s' % options.ssl_version): print( "Unknown SSL protocol %s" % - tornado.options.options.ssl_version) + options.ssl_version) sys.exit(1) ssl_opts['ssl_version'] = getattr( - ssl, 'PROTOCOL_%s' % tornado.options.options.ssl_version) + ssl, 'PROTOCOL_%s' % options.ssl_version) from butterfly import application @@ -285,6 +286,6 @@ log.info('Starting loop') ioloop = tornado.ioloop.IOLoop.instance() 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) ioloop.start() diff --git a/butterfly/routes.py b/butterfly/routes.py index 973b04c..4ba34e7 100644 --- a/butterfly/routes.py +++ b/butterfly/routes.py @@ -22,6 +22,7 @@ import tornado.options import tornado.process import tornado.web import tornado.websocket +from collections import defaultdict from butterfly import url, Route, utils, __version__ from butterfly.terminal import Terminal @@ -98,11 +99,13 @@ class Font(Route): '(?:/wd/(?P.+))?') class TermWebSocket(Route, tornado.websocket.WebSocketHandler): session_history_size = 10000 - # List of websockets per session - sessions = {} + # List of websockets per session per user + # dict: user -> dict: session -> [TermWebSocket] + sessions = defaultdict(dict) - # Terminal for session - terminals = {} + # Terminal for session per user + # dict: user -> dict: session -> Terminal + terminals = defaultdict(dict) # All terminals sockets for systemd socket deactivation sockets = [] @@ -113,6 +116,7 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler): def open(self, user, path, session): self.session = session self.closed = False + self.secure_user = None # Prevent cross domain 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.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 if session: - if session in TermWebSocket.sessions: + if session in self.user_sessions: # Session already here, registering websocket - TermWebSocket.sessions[session].append(self) + self.user_sessions[session].append(self) self.write_message(TermWebSocket.history[session]) # And returning, we don't want another terminal return else: # New session, opening terminal - TermWebSocket.sessions[session] = [self] + self.user_sessions[session] = [self] TermWebSocket.history[session] = '' terminal = Terminal( - user, path, session, - self.ws_connection.stream.socket, - self.request.headers['Host'], - self.render_string, - self.write) + user, path, session, socket, + self.request.headers['Host'], self.render_string, self.write) + + terminal.pty() 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: self._terminal = terminal - @classmethod - def close_all(cls, session): - for inst in TermWebSocket.sessions[session][:]: - inst.on_close() - inst.close() - del TermWebSocket.sessions[session] - del TermWebSocket.terminals[session] + @property + def user_sessions(self): + """Return the dict session of socket lists""" + if not self.secure_user: + return {} + return TermWebSocket.sessions[self.secure_user.name] + + @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 - 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 if len(cls.history) > cls.session_history_size: cls.history[session] = cls.history[session][ -cls.session_history_size:] - for session in cls.sessions[session][:]: + sessions = cls.sessions.get(user.name, []) + for session in sessions[session]: try: session.write_message(message) except Exception: @@ -178,9 +229,10 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler): def write(self, message): if self.session: if message is None: - TermWebSocket.close_all(self.session) + TermWebSocket.close_all(self.session, self.secure_user) else: - TermWebSocket.broadcast(self.session, message) + TermWebSocket.broadcast( + self.session, message, self.secure_user) else: if message is None: self.on_close() @@ -189,8 +241,8 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler): self.write_message(message) def on_message(self, message): - if self.session: - term = TermWebSocket.terminals.get(self.session) + if self.session and self.secure_user: + term = self.user_terminals.get(self.session) term and term.write(message) else: self._terminal.write(message) @@ -202,7 +254,7 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler): self.log.info('Websocket closed %r' % self) TermWebSocket.sockets.remove(self) if self.session: - TermWebSocket.sessions[self.session].remove(self) + self.user_sessions[self.session].remove(self) elif hasattr(self, '_terminal'): self._terminal.close() else: @@ -212,10 +264,18 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler): sys.exit(0) -@url(r'/list(?:user/(.+))?/?(?:wd/(.+))?') -class List(Route): - """List available terminals""" - def get(self, user, path): - if not tornado.options.options.unsecure and user: - raise tornado.web.HTTPError(400) - return self.render('list.html', sessions=TermWebSocket.sessions) +@url(r'/sessions') +class Sessions(Route): + """List available sessions""" + def get(self): + if tornado.options.options.unsecure: + raise tornado.web.HTTPError(403) + + 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, [])) diff --git a/butterfly/templates/list.html b/butterfly/templates/list.html index 6903146..e6a79dd 100644 --- a/butterfly/templates/list.html +++ b/butterfly/templates/list.html @@ -10,15 +10,15 @@ Butterfly - {# #} + -

Currently open butterfly sessions : -

+

Currently open butterfly sessions :

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