Skip to content

Commit

Permalink
Address cross-origin and attachments security issues (#4)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
FiloSottile committed May 25, 2020
1 parent f12a4ec commit 51c32e9
Show file tree
Hide file tree
Showing 2 changed files with 20 additions and 11 deletions.
29 changes: 19 additions & 10 deletions netviel/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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("/")
Expand All @@ -69,7 +66,7 @@ def send_index():

@app.route("/<path:path>")
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")

Expand All @@ -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()
Expand All @@ -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/<string:message_id>")
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

Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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(),
}
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)

0 comments on commit 51c32e9

Please sign in to comment.