From 5b29101c34efb8c9ea9b5d011d16502e355c8f1d Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Mon, 5 Mar 2018 11:06:59 -0800 Subject: [PATCH] Refactor web.py to move all the web logic into the Web class, and refactor onionshare (cli) to work with it -- but onionshare_gui is currently broken --- onionshare/__init__.py | 13 +- onionshare/web.py | 670 +++++++++++++++++-------------------- onionshare_gui/__init__.py | 3 +- 3 files changed, 320 insertions(+), 366 deletions(-) diff --git a/onionshare/__init__.py b/onionshare/__init__.py index f1252f12..8d914f9c 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -20,7 +20,8 @@ along with this program. If not, see . import os, sys, time, argparse, threading -from . import strings, common, web +from . import strings, common +from .web import Web from .onion import * from .onionshare import OnionShare from .settings import Settings @@ -67,14 +68,9 @@ def main(cwd=None): print(strings._('no_filenames')) sys.exit() - # Tell web if receive mode is enabled - if receive: - web.set_receive_mode() - # Debug mode? if debug: common.set_debug(debug) - web.debug_mode() # Validation valid = True @@ -88,10 +84,13 @@ def main(cwd=None): if not valid: sys.exit() - + # Load settings settings = Settings(config) settings.load() + # Create the Web object + web = Web(debug, stay_open, False, receive) + # Start the Onion object onion = Onion() try: diff --git a/onionshare/web.py b/onionshare/web.py index c7d3fae6..3e75ba27 100644 --- a/onionshare/web.py +++ b/onionshare/web.py @@ -37,424 +37,378 @@ from flask import ( from . import strings, common - -def _safe_select_jinja_autoescape(self, filename): - if filename is None: - return True - return filename.endswith(('.html', '.htm', '.xml', '.xhtml')) - -# Starting in Flask 0.11, render_template_string autoescapes template variables -# by default. To prevent content injection through template variables in -# earlier versions of Flask, we force autoescaping in the Jinja2 template -# engine if we detect a Flask version with insecure default behavior. -if Version(flask_version) < Version('0.11'): - # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc - Flask.select_jinja_autoescape = _safe_select_jinja_autoescape - -app = Flask(__name__) - -# information about the file -file_info = [] -zip_filename = None -zip_filesize = None - -security_headers = [ - ('Content-Security-Policy', 'default-src \'self\'; style-src \'unsafe-inline\'; script-src \'unsafe-inline\'; img-src \'self\' data:;'), - ('X-Frame-Options', 'DENY'), - ('X-Xss-Protection', '1; mode=block'), - ('X-Content-Type-Options', 'nosniff'), - ('Referrer-Policy', 'no-referrer'), - ('Server', 'OnionShare') -] - - -def set_file_info(filenames, processed_size_callback=None): +class Web(object): """ - Using the list of filenames being shared, fill in details that the web - page will need to display. This includes zipping up the file in order to - get the zip file's name and size. + The Web object is the OnionShare web server, powered by flask """ - global file_info, zip_filename, zip_filesize + def __init__(self, debug, stay_open, gui_mode, receive_mode): + # The flask app + self.app = Flask(__name__) - # build file info list - file_info = {'files': [], 'dirs': []} - for filename in filenames: - info = { - 'filename': filename, - 'basename': os.path.basename(filename.rstrip('/')) - } - if os.path.isfile(filename): - info['size'] = os.path.getsize(filename) - info['size_human'] = common.human_readable_filesize(info['size']) - file_info['files'].append(info) - if os.path.isdir(filename): - info['size'] = common.dir_size(filename) - info['size_human'] = common.human_readable_filesize(info['size']) - file_info['dirs'].append(info) - file_info['files'] = sorted(file_info['files'], key=lambda k: k['basename']) - file_info['dirs'] = sorted(file_info['dirs'], key=lambda k: k['basename']) + # Debug mode? + if debug: + self.debug_mode() - # zip up the files and folders - z = common.ZipWriter(processed_size_callback=processed_size_callback) - for info in file_info['files']: - z.add_file(info['filename']) - for info in file_info['dirs']: - z.add_dir(info['filename']) - z.close() - zip_filename = z.zip_filename - zip_filesize = os.path.getsize(zip_filename) + # Stay open after the first download? + self.stay_open = False + + # Are we running in GUI mode? + self.gui_mode = False + + # Are we using receive mode? + self.receive_mode = False -REQUEST_LOAD = 0 -REQUEST_DOWNLOAD = 1 -REQUEST_PROGRESS = 2 -REQUEST_OTHER = 3 -REQUEST_CANCELED = 4 -REQUEST_RATE_LIMIT = 5 -q = queue.Queue() + # Starting in Flask 0.11, render_template_string autoescapes template variables + # by default. To prevent content injection through template variables in + # earlier versions of Flask, we force autoescaping in the Jinja2 template + # engine if we detect a Flask version with insecure default behavior. + if Version(flask_version) < Version('0.11'): + # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc + Flask.select_jinja_autoescape = self._safe_select_jinja_autoescape + # Information about the file + self.file_info = [] + self.zip_filename = None + self.zip_filesize = None -def add_request(request_type, path, data=None): - """ - Add a request to the queue, to communicate with the GUI. - """ - global q - q.put({ - 'type': request_type, - 'path': path, - 'data': data - }) + self.security_headers = [ + ('Content-Security-Policy', 'default-src \'self\'; style-src \'unsafe-inline\'; script-src \'unsafe-inline\'; img-src \'self\' data:;'), + ('X-Frame-Options', 'DENY'), + ('X-Xss-Protection', '1; mode=block'), + ('X-Content-Type-Options', 'nosniff'), + ('Referrer-Policy', 'no-referrer'), + ('Server', 'OnionShare') + ] + self.REQUEST_LOAD = 0 + self.REQUEST_DOWNLOAD = 1 + self.REQUEST_PROGRESS = 2 + self.REQUEST_OTHER = 3 + self.REQUEST_CANCELED = 4 + self.REQUEST_RATE_LIMIT = 5 + self.q = queue.Queue() -# Load and base64 encode images to pass into templates -favicon_b64 = base64.b64encode(open(common.get_resource_path('images/favicon.ico'), 'rb').read()).decode() -logo_b64 = base64.b64encode(open(common.get_resource_path('images/logo.png'), 'rb').read()).decode() -folder_b64 = base64.b64encode(open(common.get_resource_path('images/web_folder.png'), 'rb').read()).decode() -file_b64 = base64.b64encode(open(common.get_resource_path('images/web_file.png'), 'rb').read()).decode() + # Load and base64 encode images to pass into templates + self.favicon_b64 = self.base64_image('favicon.ico') + self.logo_b64 = self.base64_image('logo.png') + self.folder_b64 = self.base64_image('web_folder.png') + self.file_b64 = self.base64_image('web_file.png') -slug = None + self.slug = None + self.download_count = 0 + self.error404_count = 0 -def generate_slug(persistent_slug=''): - global slug - if persistent_slug: - slug = persistent_slug - else: - slug = common.build_slug() + # If "Stop After First Download" is checked (stay_open == False), only allow + # one download at a time. + self.download_in_progress = False -download_count = 0 -error404_count = 0 + self.done = False -stay_open = False + # If the client closes the OnionShare window while a download is in progress, + # it should immediately stop serving the file. The client_cancel global is + # used to tell the download function that the client is canceling the download. + self.client_cancel = False + # shutting down the server only works within the context of flask, so the easiest way to do it is over http + self.shutdown_slug = common.random_string(16) -def set_stay_open(new_stay_open): - """ - Set stay_open variable. - """ - global stay_open - stay_open = new_stay_open + @self.app.route("/") + def index(slug_candidate): + """ + Render the template for the onionshare landing page. + """ + self.check_slug_candidate(slug_candidate) + self.add_request(self.REQUEST_LOAD, request.path) -def get_stay_open(): - """ - Get stay_open variable. - """ - return stay_open + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.stay_open and self.download_in_progress + if deny_download: + r = make_response(render_template_string( + open(common.get_resource_path('html/denied.html')).read(), + favicon_b64=self.favicon_b64 + )) + for header, value in self.security_headers: + r.headers.set(header, value) + return r + # If download is allowed to continue, serve download page + r = make_response(render_template_string( + open(common.get_resource_path('html/index.html')).read(), + favicon_b64=self.favicon_b64, + logo_b64=self.logo_b64, + folder_b64=self.folder_b64, + file_b64=self.file_b64, + slug=self.slug, + file_info=self.file_info, + filename=os.path.basename(self.zip_filename), + filesize=self.zip_filesize, + filesize_human=common.human_readable_filesize(self.zip_filesize))) + for header, value in self.security_headers: + r.headers.set(header, value) + return r -# Are we running in GUI mode? -gui_mode = False + @self.app.route("//download") + def download(slug_candidate): + """ + Download the zip file. + """ + self.check_slug_candidate(slug_candidate) + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.stay_open and self.download_in_progress + if deny_download: + r = make_response(render_template_string( + open(common.get_resource_path('html/denied.html')).read(), + favicon_b64=self.favicon_b64 + )) + for header,value in self.security_headers: + r.headers.set(header, value) + return r -def set_gui_mode(): - """ - Tell the web service that we're running in GUI mode - """ - global gui_mode - gui_mode = True + # each download has a unique id + download_id = self.download_count + self.download_count += 1 + # prepare some variables to use inside generate() function below + # which is outside of the request context + shutdown_func = request.environ.get('werkzeug.server.shutdown') + path = request.path -# Are we using receive mode? -receive_mode = False + # tell GUI the download started + self.add_request(self.REQUEST_DOWNLOAD, path, {'id': download_id}) + dirname = os.path.dirname(self.zip_filename) + basename = os.path.basename(self.zip_filename) -def set_receive_mode(): - """ - Tell the web service that we're running in GUI mode - """ - global receive_mode - receive_mode = True - print('receive mode enabled') + def generate(): + # The user hasn't canceled the download + self.client_cancel = False + # Starting a new download + if not self.stay_open: + self.download_in_progress = True -def debug_mode(): - """ - Turn on debugging mode, which will log flask errors to a debug file. - """ - temp_dir = tempfile.gettempdir() - log_handler = logging.FileHandler( - os.path.join(temp_dir, 'onionshare_server.log')) - log_handler.setLevel(logging.WARNING) - app.logger.addHandler(log_handler) + chunk_size = 102400 # 100kb + fp = open(self.zip_filename, 'rb') + self.done = False + canceled = False + while not self.done: + # The user has canceled the download, so stop serving the file + if self.client_cancel: + self.add_request(self.REQUEST_CANCELED, path, {'id': download_id}) + break -def check_slug_candidate(slug_candidate, slug_compare=None): - if not slug_compare: - slug_compare = slug - if not hmac.compare_digest(slug_compare, slug_candidate): - abort(404) + chunk = fp.read(chunk_size) + if chunk == b'': + self.done = True + else: + try: + yield chunk + # tell GUI the progress + downloaded_bytes = fp.tell() + percent = (1.0 * downloaded_bytes / self.zip_filesize) * 100 -# If "Stop After First Download" is checked (stay_open == False), only allow -# one download at a time. -download_in_progress = False + # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) + plat = common.get_platform() + if not self.gui_mode or plat == 'Linux' or plat == 'BSD': + sys.stdout.write( + "\r{0:s}, {1:.2f}% ".format(common.human_readable_filesize(downloaded_bytes), percent)) + sys.stdout.flush() -done = False + self.add_request(self.REQUEST_PROGRESS, path, {'id': download_id, 'bytes': downloaded_bytes}) + self.done = False + except: + # looks like the download was canceled + self.done = True + canceled = True -@app.route("/") -def index(slug_candidate): - """ - Render the template for the onionshare landing page. - """ - check_slug_candidate(slug_candidate) + # tell the GUI the download has canceled + self.add_request(self.REQUEST_CANCELED, path, {'id': download_id}) - add_request(REQUEST_LOAD, request.path) + fp.close() - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - global stay_open, download_in_progress - deny_download = not stay_open and download_in_progress - if deny_download: - r = make_response(render_template_string( - open(common.get_resource_path('html/denied.html')).read(), - favicon_b64=favicon_b64 - )) - for header, value in security_headers: - r.headers.set(header, value) - return r + if common.get_platform() != 'Darwin': + sys.stdout.write("\n") - # If download is allowed to continue, serve download page + # Download is finished + if not self.stay_open: + self.download_in_progress = False - r = make_response(render_template_string( - open(common.get_resource_path('html/index.html')).read(), - favicon_b64=favicon_b64, - logo_b64=logo_b64, - folder_b64=folder_b64, - file_b64=file_b64, - slug=slug, - file_info=file_info, - filename=os.path.basename(zip_filename), - filesize=zip_filesize, - filesize_human=common.human_readable_filesize(zip_filesize))) - for header, value in security_headers: - r.headers.set(header, value) - return r + # Close the server, if necessary + if not self.stay_open and not canceled: + print(strings._("closing_automatically")) + if shutdown_func is None: + raise RuntimeError('Not running with the Werkzeug Server') + shutdown_func() + r = Response(generate()) + r.headers.set('Content-Length', self.zip_filesize) + r.headers.set('Content-Disposition', 'attachment', filename=basename) + for header,value in self.security_headers: + r.headers.set(header, value) + # guess content type + (content_type, _) = mimetypes.guess_type(basename, strict=False) + if content_type is not None: + r.headers.set('Content-Type', content_type) + return r -# If the client closes the OnionShare window while a download is in progress, -# it should immediately stop serving the file. The client_cancel global is -# used to tell the download function that the client is canceling the download. -client_cancel = False + @self.app.errorhandler(404) + def page_not_found(e): + """ + 404 error page. + """ + self.add_request(self.REQUEST_OTHER, request.path) + if request.path != '/favicon.ico': + self.error404_count += 1 + if self.error404_count == 20: + self.add_request(self.REQUEST_RATE_LIMIT, request.path) + force_shutdown() + print(strings._('error_rate_limit')) -@app.route("//download") -def download(slug_candidate): - """ - Download the zip file. - """ - check_slug_candidate(slug_candidate) + r = make_response(render_template_string( + open(common.get_resource_path('html/404.html')).read(), + favicon_b64=self.favicon_b64 + ), 404) + for header, value in self.security_headers: + r.headers.set(header, value) + return r - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - global stay_open, download_in_progress, done - deny_download = not stay_open and download_in_progress - if deny_download: - r = make_response(render_template_string( - open(common.get_resource_path('html/denied.html')).read(), - favicon_b64=favicon_b64 - )) - for header,value in security_headers: - r.headers.set(header, value) - return r - - global download_count - - # each download has a unique id - download_id = download_count - download_count += 1 - - # prepare some variables to use inside generate() function below - # which is outside of the request context - shutdown_func = request.environ.get('werkzeug.server.shutdown') - path = request.path - - # tell GUI the download started - add_request(REQUEST_DOWNLOAD, path, {'id': download_id}) - - dirname = os.path.dirname(zip_filename) - basename = os.path.basename(zip_filename) - - def generate(): - # The user hasn't canceled the download - global client_cancel, gui_mode - client_cancel = False - - # Starting a new download - global stay_open, download_in_progress, done - if not stay_open: - download_in_progress = True - - chunk_size = 102400 # 100kb - - fp = open(zip_filename, 'rb') - done = False - canceled = False - while not done: - # The user has canceled the download, so stop serving the file - if client_cancel: - add_request(REQUEST_CANCELED, path, {'id': download_id}) - break - - chunk = fp.read(chunk_size) - if chunk == b'': - done = True - else: - try: - yield chunk - - # tell GUI the progress - downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / zip_filesize) * 100 - - # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) - plat = common.get_platform() - if not gui_mode or plat == 'Linux' or plat == 'BSD': - sys.stdout.write( - "\r{0:s}, {1:.2f}% ".format(common.human_readable_filesize(downloaded_bytes), percent)) - sys.stdout.flush() - - add_request(REQUEST_PROGRESS, path, {'id': download_id, 'bytes': downloaded_bytes}) - done = False - except: - # looks like the download was canceled - done = True - canceled = True - - # tell the GUI the download has canceled - add_request(REQUEST_CANCELED, path, {'id': download_id}) - - fp.close() - - if common.get_platform() != 'Darwin': - sys.stdout.write("\n") - - # Download is finished - if not stay_open: - download_in_progress = False - - # Close the server, if necessary - if not stay_open and not canceled: - print(strings._("closing_automatically")) - if shutdown_func is None: - raise RuntimeError('Not running with the Werkzeug Server') - shutdown_func() - - r = Response(generate()) - r.headers.set('Content-Length', zip_filesize) - r.headers.set('Content-Disposition', 'attachment', filename=basename) - for header,value in security_headers: - r.headers.set(header, value) - # guess content type - (content_type, _) = mimetypes.guess_type(basename, strict=False) - if content_type is not None: - r.headers.set('Content-Type', content_type) - return r - - -@app.errorhandler(404) -def page_not_found(e): - """ - 404 error page. - """ - add_request(REQUEST_OTHER, request.path) - - global error404_count - if request.path != '/favicon.ico': - error404_count += 1 - if error404_count == 20: - add_request(REQUEST_RATE_LIMIT, request.path) + @self.app.route("//shutdown") + def shutdown(slug_candidate): + """ + Stop the flask web server, from the context of an http request. + """ + check_slug_candidate(slug_candidate, shutdown_slug) force_shutdown() - print(strings._('error_rate_limit')) + return "" - r = make_response(render_template_string( - open(common.get_resource_path('html/404.html')).read(), - favicon_b64=favicon_b64 - ), 404) - for header, value in security_headers: - r.headers.set(header, value) - return r + def set_file_info(self, filenames, processed_size_callback=None): + """ + Using the list of filenames being shared, fill in details that the web + page will need to display. This includes zipping up the file in order to + get the zip file's name and size. + """ + # build file info list + self.file_info = {'files': [], 'dirs': []} + for filename in filenames: + info = { + 'filename': filename, + 'basename': os.path.basename(filename.rstrip('/')) + } + if os.path.isfile(filename): + info['size'] = os.path.getsize(filename) + info['size_human'] = common.human_readable_filesize(info['size']) + self.file_info['files'].append(info) + if os.path.isdir(filename): + info['size'] = common.dir_size(filename) + info['size_human'] = common.human_readable_filesize(info['size']) + self.file_info['dirs'].append(info) + self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) + self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) + # zip up the files and folders + z = common.ZipWriter(processed_size_callback=processed_size_callback) + for info in self.file_info['files']: + z.add_file(info['filename']) + for info in self.file_info['dirs']: + z.add_dir(info['filename']) + z.close() + self.zip_filename = z.zip_filename + self.zip_filesize = os.path.getsize(self.zip_filename) -# shutting down the server only works within the context of flask, so the easiest way to do it is over http -shutdown_slug = common.random_string(16) + def _safe_select_jinja_autoescape(self, filename): + if filename is None: + return True + return filename.endswith(('.html', '.htm', '.xml', '.xhtml')) + def base64_image(self, filename): + """ + Base64-encode an image file to use data URIs in the web app + """ + return base64.b64encode(open(common.get_resource_path('images/{}'.format(filename)), 'rb').read()).decode() -@app.route("//shutdown") -def shutdown(slug_candidate): - """ - Stop the flask web server, from the context of an http request. - """ - check_slug_candidate(slug_candidate, shutdown_slug) - force_shutdown() - return "" + def add_request(self, request_type, path, data=None): + """ + Add a request to the queue, to communicate with the GUI. + """ + self.q.put({ + 'type': request_type, + 'path': path, + 'data': data + }) + def generate_slug(self, persistent_slug=''): + if persistent_slug: + self.slug = persistent_slug + else: + self.slug = common.build_slug() -def force_shutdown(): - """ - Stop the flask web server, from the context of the flask app. - """ - # shutdown the flask service - func = request.environ.get('werkzeug.server.shutdown') - if func is None: - raise RuntimeError('Not running with the Werkzeug Server') - func() + def debug_mode(self): + """ + Turn on debugging mode, which will log flask errors to a debug file. + """ + temp_dir = tempfile.gettempdir() + log_handler = logging.FileHandler( + os.path.join(temp_dir, 'onionshare_server.log')) + log_handler.setLevel(logging.WARNING) + self.app.logger.addHandler(log_handler) + def check_slug_candidate(self, slug_candidate, slug_compare=None): + if not slug_compare: + slug_compare = self.slug + if not hmac.compare_digest(slug_compare, slug_candidate): + abort(404) -def start(port, stay_open=False, persistent_slug=''): - """ - Start the flask web server. - """ - generate_slug(persistent_slug) + def force_shutdown(self): + """ + Stop the flask web server, from the context of the flask app. + """ + # shutdown the flask service + func = request.environ.get('werkzeug.server.shutdown') + if func is None: + raise RuntimeError('Not running with the Werkzeug Server') + func() - set_stay_open(stay_open) + def start(self, port, stay_open=False, persistent_slug=''): + """ + Start the flask web server. + """ + self.generate_slug(persistent_slug) - # In Whonix, listen on 0.0.0.0 instead of 127.0.0.1 (#220) - if os.path.exists('/usr/share/anon-ws-base-files/workstation'): - host = '0.0.0.0' - else: - host = '127.0.0.1' + self.stay_open = stay_open - app.run(host=host, port=port, threaded=True) + # In Whonix, listen on 0.0.0.0 instead of 127.0.0.1 (#220) + if os.path.exists('/usr/share/anon-ws-base-files/workstation'): + host = '0.0.0.0' + else: + host = '127.0.0.1' + self.app.run(host=host, port=port, threaded=True) -def stop(port): - """ - Stop the flask web server by loading /shutdown. - """ + def stop(self, port): + """ + Stop the flask web server by loading /shutdown. + """ - # If the user cancels the download, let the download function know to stop - # serving the file - global client_cancel - client_cancel = True + # If the user cancels the download, let the download function know to stop + # serving the file + self.client_cancel = True - # to stop flask, load http://127.0.0.1://shutdown - try: - s = socket.socket() - s.connect(('127.0.0.1', port)) - s.sendall('GET /{0:s}/shutdown HTTP/1.1\r\n\r\n'.format(shutdown_slug)) - except: + # to stop flask, load http://127.0.0.1://shutdown try: - urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, shutdown_slug)).read() + s = socket.socket() + s.connect(('127.0.0.1', port)) + s.sendall('GET /{0:s}/shutdown HTTP/1.1\r\n\r\n'.format(shutdown_slug)) except: - pass + try: + urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, shutdown_slug)).read() + except: + pass diff --git a/onionshare_gui/__init__.py b/onionshare_gui/__init__.py index 24e627bb..a40c081f 100644 --- a/onionshare_gui/__init__.py +++ b/onionshare_gui/__init__.py @@ -22,7 +22,8 @@ import os, sys, platform, argparse from .alert import Alert from PyQt5 import QtCore, QtWidgets -from onionshare import strings, common, web +from onionshare import strings, common +from .web import Web from onionshare.onion import Onion from onionshare.onionshare import OnionShare from onionshare.settings import Settings