From 51c32e9a30f4aaf7706a01c4a0e570954cd55ba7 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Mon, 25 May 2020 14:01:47 -0400 Subject: [PATCH] Address cross-origin and attachments security issues (#4) * Drop globally allowed CORS This is a major security issue, as it allows any website visited on the same machine to query the API when the server is running. * Serve attachments and raw emails as downloads Otherwise, a text/html attachment will be served as an HTML page able to execute JS and exfiltrate emails. * Protect contents from cross-origin attacks Using Spectre, other websites can extract the contents of certain responses even if the CORS policy doesn't allow it. Use Cross-Origin Resource Policy (CORP) to globally opt into Cross-Origin Read Blocking (CORB), which blocks the contents from being delivered to other origins at all. By default, CORB only protects HTML and JSON, under the expectation that other content types are gated by credentials or CSRF tokens, but that's not the case here (and I can't edit the JS to make it pass tokens). An alternative could have been to check the Origin header, but it looks like it's not even clear if it's supposed to be sent with cross-origin GET fetches. While at it, also throw in the new fancy Cross Origin Opener Policy (COOP) to isolate the document from other origins that might open it in a new window. I think Cross Origin Embedder Policy (COEP) is not relevant to us because it's embedder (attacker) side. This really should be easier than this, but also CPUs should work. * Minor security hardening fixes --- netviel/api.py | 29 +++++++++++++++++++---------- setup.py | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/netviel/api.py b/netviel/api.py index 9828adb..04a03a5 100644 --- a/netviel/api.py +++ b/netviel/api.py @@ -9,8 +9,7 @@ import bleach import notmuch -from flask import Flask, current_app, g, send_file, send_from_directory -from flask_cors import CORS +from flask import Flask, current_app, g, send_file, send_from_directory, safe_join from flask_restful import Api, Resource ALLOWED_TAGS = [ @@ -59,8 +58,6 @@ def create_app(): app.config["NOTMUCH_PATH"] = os.getenv("NOTMUCH_PATH") app.logger.setLevel(logging.INFO) - CORS(app) - api = Api(app) @app.route("/") @@ -69,7 +66,7 @@ def send_index(): @app.route("/") def send_js(path): - if path and os.path.exists(os.path.join(app.static_folder, path)): + if path and os.path.exists(safe_join(app.static_folder, path)): return send_from_directory(app.static_folder, path) return send_from_directory(app.static_folder, "index.html") @@ -79,6 +76,14 @@ def before_request(): app.teardown_appcontext(close_db) + @app.after_request + def security_headers(response): + response.headers["Cross-Origin-Resource-Policy"] = "same-origin" + response.headers["Cross-Origin-Opener-Policy"] = "same-origin" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "SAMEORIGIN" + return response + class Query(Resource): def get(self, query_string): threads = notmuch.Query(get_db(), query_string).search_threads() @@ -104,17 +109,18 @@ def download_attachment(message_id, num): if not d: return None if isinstance(d["content"], str): - f = io.BytesIO(d["content"].encode()) + f = io.StringIO(d["content"]) else: f = io.BytesIO(d["content"]) - f.seek(0) - return send_file(f, mimetype=d["content_type"]) + return send_file(f, mimetype=d["content_type"], as_attachment=True, + attachment_filename=d["filename"]) @app.route("/api/message/") def download_message(message_id): msgs = notmuch.Query(get_db(), "mid:{}".format(message_id)).search_messages() msg = next(msgs) # there can be only 1 - return send_file(msg.get_filename(), mimetype="message/rfc822") + return send_file(msg.get_filename(), mimetype="message/rfc822", + as_attachment=True, attachment_filename=message_id+".eml") return app @@ -172,8 +178,10 @@ def message_to_json(message): attributes=ALLOWED_ATTRIBUTES, strip=True, ) - else: + elif content_type == "text/plain": content = msg_body.get_content() + else: + return {} return { "from": email_msg["From"], "to": email_msg["To"], @@ -202,6 +210,7 @@ def message_attachment(message, num): return {} attachment = attachments[num] return { + "filename": attachment.get_filename(), "content_type": attachment.get_content_type(), "content": attachment.get_content(), } diff --git a/setup.py b/setup.py index ca0698c..416271e 100644 --- a/setup.py +++ b/setup.py @@ -23,5 +23,5 @@ license="MIT", packages=find_packages(), package_data={"netviel": PACKAGE_DATA}, - install_requires=["flask", "flask-restful", "flask-cors", "bleach"], + install_requires=["flask", "flask-restful", "bleach"], )