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

This commit is contained in:
Micah Lee 2018-03-05 11:06:59 -08:00
parent 08957c5145
commit 0cec696055
No known key found for this signature in database
GPG Key ID: 403C2657CD994F73
3 changed files with 320 additions and 366 deletions

View File

@ -20,7 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import os, sys, time, argparse, threading import os, sys, time, argparse, threading
from . import strings, common, web from . import strings, common
from .web import Web
from .onion import * from .onion import *
from .onionshare import OnionShare from .onionshare import OnionShare
from .settings import Settings from .settings import Settings
@ -67,14 +68,9 @@ def main(cwd=None):
print(strings._('no_filenames')) print(strings._('no_filenames'))
sys.exit() sys.exit()
# Tell web if receive mode is enabled
if receive:
web.set_receive_mode()
# Debug mode? # Debug mode?
if debug: if debug:
common.set_debug(debug) common.set_debug(debug)
web.debug_mode()
# Validation # Validation
valid = True valid = True
@ -88,10 +84,13 @@ def main(cwd=None):
if not valid: if not valid:
sys.exit() sys.exit()
# Load settings
settings = Settings(config) settings = Settings(config)
settings.load() settings.load()
# Create the Web object
web = Web(debug, stay_open, False, receive)
# Start the Onion object # Start the Onion object
onion = Onion() onion = Onion()
try: try:

View File

@ -37,424 +37,378 @@ from flask import (
from . import strings, common from . import strings, common
class Web(object):
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):
""" """
Using the list of filenames being shared, fill in details that the web The Web object is the OnionShare web server, powered by flask
page will need to display. This includes zipping up the file in order to
get the zip file's name and size.
""" """
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 # Debug mode?
file_info = {'files': [], 'dirs': []} if debug:
for filename in filenames: self.debug_mode()
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'])
# zip up the files and folders # Stay open after the first download?
z = common.ZipWriter(processed_size_callback=processed_size_callback) self.stay_open = False
for info in file_info['files']:
z.add_file(info['filename']) # Are we running in GUI mode?
for info in file_info['dirs']: self.gui_mode = False
z.add_dir(info['filename'])
z.close() # Are we using receive mode?
zip_filename = z.zip_filename self.receive_mode = False
zip_filesize = os.path.getsize(zip_filename)
REQUEST_LOAD = 0 # Starting in Flask 0.11, render_template_string autoescapes template variables
REQUEST_DOWNLOAD = 1 # by default. To prevent content injection through template variables in
REQUEST_PROGRESS = 2 # earlier versions of Flask, we force autoescaping in the Jinja2 template
REQUEST_OTHER = 3 # engine if we detect a Flask version with insecure default behavior.
REQUEST_CANCELED = 4 if Version(flask_version) < Version('0.11'):
REQUEST_RATE_LIMIT = 5 # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc
q = queue.Queue() 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): self.security_headers = [
""" ('Content-Security-Policy', 'default-src \'self\'; style-src \'unsafe-inline\'; script-src \'unsafe-inline\'; img-src \'self\' data:;'),
Add a request to the queue, to communicate with the GUI. ('X-Frame-Options', 'DENY'),
""" ('X-Xss-Protection', '1; mode=block'),
global q ('X-Content-Type-Options', 'nosniff'),
q.put({ ('Referrer-Policy', 'no-referrer'),
'type': request_type, ('Server', 'OnionShare')
'path': path, ]
'data': data
})
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 # Load and base64 encode images to pass into templates
favicon_b64 = base64.b64encode(open(common.get_resource_path('images/favicon.ico'), 'rb').read()).decode() self.favicon_b64 = self.base64_image('favicon.ico')
logo_b64 = base64.b64encode(open(common.get_resource_path('images/logo.png'), 'rb').read()).decode() self.logo_b64 = self.base64_image('logo.png')
folder_b64 = base64.b64encode(open(common.get_resource_path('images/web_folder.png'), 'rb').read()).decode() self.folder_b64 = self.base64_image('web_folder.png')
file_b64 = base64.b64encode(open(common.get_resource_path('images/web_file.png'), 'rb').read()).decode() 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=''): # If "Stop After First Download" is checked (stay_open == False), only allow
global slug # one download at a time.
if persistent_slug: self.download_in_progress = False
slug = persistent_slug
else:
slug = common.build_slug()
download_count = 0 self.done = False
error404_count = 0
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): @self.app.route("/<slug_candidate>")
""" def index(slug_candidate):
Set stay_open variable. """
""" Render the template for the onionshare landing page.
global stay_open """
stay_open = new_stay_open self.check_slug_candidate(slug_candidate)
self.add_request(self.REQUEST_LOAD, request.path)
def get_stay_open(): # Deny new downloads if "Stop After First Download" is checked and there is
""" # currently a download
Get stay_open variable. deny_download = not self.stay_open and self.download_in_progress
""" if deny_download:
return stay_open 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? @self.app.route("/<slug_candidate>/download")
gui_mode = False 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(): # each download has a unique id
""" download_id = self.download_count
Tell the web service that we're running in GUI mode self.download_count += 1
"""
global gui_mode
gui_mode = True
# 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? # tell GUI the download started
receive_mode = False 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(): def generate():
""" # The user hasn't canceled the download
Tell the web service that we're running in GUI mode self.client_cancel = False
"""
global receive_mode
receive_mode = True
print('receive mode enabled')
# Starting a new download
if not self.stay_open:
self.download_in_progress = True
def debug_mode(): chunk_size = 102400 # 100kb
"""
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)
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): chunk = fp.read(chunk_size)
if not slug_compare: if chunk == b'':
slug_compare = slug self.done = True
if not hmac.compare_digest(slug_compare, slug_candidate): else:
abort(404) 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 # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304)
# one download at a time. plat = common.get_platform()
download_in_progress = False 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("/<slug_candidate>") # tell the GUI the download has canceled
def index(slug_candidate): self.add_request(self.REQUEST_CANCELED, path, {'id': download_id})
"""
Render the template for the onionshare landing page.
"""
check_slug_candidate(slug_candidate)
add_request(REQUEST_LOAD, request.path) fp.close()
# Deny new downloads if "Stop After First Download" is checked and there is if common.get_platform() != 'Darwin':
# currently a download sys.stdout.write("\n")
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 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( # Close the server, if necessary
open(common.get_resource_path('html/index.html')).read(), if not self.stay_open and not canceled:
favicon_b64=favicon_b64, print(strings._("closing_automatically"))
logo_b64=logo_b64, if shutdown_func is None:
folder_b64=folder_b64, raise RuntimeError('Not running with the Werkzeug Server')
file_b64=file_b64, shutdown_func()
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
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, @self.app.errorhandler(404)
# it should immediately stop serving the file. The client_cancel global is def page_not_found(e):
# used to tell the download function that the client is canceling the download. """
client_cancel = False 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("/<slug_candidate>/download") r = make_response(render_template_string(
def download(slug_candidate): open(common.get_resource_path('html/404.html')).read(),
""" favicon_b64=self.favicon_b64
Download the zip file. ), 404)
""" for header, value in self.security_headers:
check_slug_candidate(slug_candidate) r.headers.set(header, value)
return r
# Deny new downloads if "Stop After First Download" is checked and there is @self.app.route("/<slug_candidate>/shutdown")
# currently a download def shutdown(slug_candidate):
global stay_open, download_in_progress, done """
deny_download = not stay_open and download_in_progress Stop the flask web server, from the context of an http request.
if deny_download: """
r = make_response(render_template_string( check_slug_candidate(slug_candidate, shutdown_slug)
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)
force_shutdown() force_shutdown()
print(strings._('error_rate_limit')) return ""
r = make_response(render_template_string( def set_file_info(self, filenames, processed_size_callback=None):
open(common.get_resource_path('html/404.html')).read(), """
favicon_b64=favicon_b64 Using the list of filenames being shared, fill in details that the web
), 404) page will need to display. This includes zipping up the file in order to
for header, value in security_headers: get the zip file's name and size.
r.headers.set(header, value) """
return r # 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 def _safe_select_jinja_autoescape(self, filename):
shutdown_slug = common.random_string(16) 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("/<slug_candidate>/shutdown") def add_request(self, request_type, path, data=None):
def shutdown(slug_candidate): """
""" Add a request to the queue, to communicate with the GUI.
Stop the flask web server, from the context of an http request. """
""" self.q.put({
check_slug_candidate(slug_candidate, shutdown_slug) 'type': request_type,
force_shutdown() 'path': path,
return "" 'data': data
})
def generate_slug(self, persistent_slug=''):
if persistent_slug:
self.slug = persistent_slug
else:
self.slug = common.build_slug()
def force_shutdown(): def debug_mode(self):
""" """
Stop the flask web server, from the context of the flask app. Turn on debugging mode, which will log flask errors to a debug file.
""" """
# shutdown the flask service temp_dir = tempfile.gettempdir()
func = request.environ.get('werkzeug.server.shutdown') log_handler = logging.FileHandler(
if func is None: os.path.join(temp_dir, 'onionshare_server.log'))
raise RuntimeError('Not running with the Werkzeug Server') log_handler.setLevel(logging.WARNING)
func() 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=''): def force_shutdown(self):
""" """
Start the flask web server. Stop the flask web server, from the context of the flask app.
""" """
generate_slug(persistent_slug) # 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) self.stay_open = stay_open
if os.path.exists('/usr/share/anon-ws-base-files/workstation'):
host = '0.0.0.0'
else:
host = '127.0.0.1'
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): def stop(self, port):
""" """
Stop the flask web server by loading /shutdown. Stop the flask web server by loading /shutdown.
""" """
# If the user cancels the download, let the download function know to stop # If the user cancels the download, let the download function know to stop
# serving the file # serving the file
global client_cancel self.client_cancel = True
client_cancel = True
# to stop flask, load http://127.0.0.1:<port>/<shutdown_slug>/shutdown # to stop flask, load http://127.0.0.1:<port>/<shutdown_slug>/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:
try: 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: except:
pass try:
urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, shutdown_slug)).read()
except:
pass

View File

@ -22,7 +22,8 @@ import os, sys, platform, argparse
from .alert import Alert from .alert import Alert
from PyQt5 import QtCore, QtWidgets 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.onion import Onion
from onionshare.onionshare import OnionShare from onionshare.onionshare import OnionShare
from onionshare.settings import Settings from onionshare.settings import Settings