From 672ff49d6d79f13d2574396bf65d8ac9d9997ee3 Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 22 May 2020 15:20:26 -0500 Subject: [PATCH] implement content-security-policy, static assets cache bust, and fix stripe back button ratchet issue because the only way to use stripe checkout is to run their proprietary JS, and we arent using a SPA, naturally what happens is, when you land on the stripe payment page if you hit the back button it goes back to the same page where you got re-directed to stripe. this commit fixes that. --- capsulflask/__init__.py | 79 +++++++++++++++---- capsulflask/console.py | 9 ++- capsulflask/db.py | 2 +- capsulflask/db_model.py | 15 ++++ capsulflask/payment.py | 42 +++++++++- .../05_down_stripe_payment_redirect.sql | 3 + .../05_up_stripe_payment_redirect.sql | 4 + capsulflask/static/create-capsul.js | 9 +++ capsulflask/static/pay-with-stripe.js | 39 +++++++++ capsulflask/static/style.css | 6 +- capsulflask/templates/base.html | 1 - capsulflask/templates/create-capsul.html | 11 +-- capsulflask/templates/stripe.html | 47 ++++++----- 13 files changed, 202 insertions(+), 65 deletions(-) create mode 100644 capsulflask/schema_migrations/05_down_stripe_payment_redirect.sql create mode 100644 capsulflask/schema_migrations/05_up_stripe_payment_redirect.sql create mode 100644 capsulflask/static/create-capsul.js create mode 100644 capsulflask/static/pay-with-stripe.js diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index 715bf6d..e18e9b1 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -2,12 +2,15 @@ import logging from logging.config import dictConfig as logging_dict_config import os +import hashlib import stripe from dotenv import load_dotenv, find_dotenv from flask import Flask from flask_mail import Mail from flask import render_template +from flask import url_for +from flask import current_app from capsulflask import virt_model, cli from capsulflask.btcpay import client as btcpay @@ -45,19 +48,19 @@ app.config.from_mapping( ) logging_dict_config({ - 'version': 1, - 'formatters': {'default': { - 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', - }}, - 'handlers': {'wsgi': { - 'class': 'logging.StreamHandler', - 'stream': 'ext://flask.logging.wsgi_errors_stream', - 'formatter': 'default' - }}, - 'root': { - 'level': app.config['LOG_LEVEL'], - 'handlers': ['wsgi'] - } + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }}, + 'root': { + 'level': app.config['LOG_LEVEL'], + 'handlers': ['wsgi'] + } }) # app.logger.critical("critical") @@ -72,9 +75,9 @@ stripe.api_version = app.config['STRIPE_API_VERSION'] app.config['FLASK_MAIL_INSTANCE'] = Mail(app) if app.config['VIRTUALIZATION_MODEL'] == "shell_scripts": - app.config['VIRTUALIZATION_MODEL'] = virt_model.ShellScriptVirtualization() + app.config['VIRTUALIZATION_MODEL'] = virt_model.ShellScriptVirtualization() else: - app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() + app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY']) @@ -93,3 +96,49 @@ app.register_blueprint(cli.bp) app.add_url_rule("/", endpoint="index") +@app.after_request +def security_headers(response): + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + if 'Content-Security-Policy' not in response.headers: + response.headers['Content-Security-Policy'] = "default-src 'self'" + response.headers['X-Content-Type-Options'] = 'nosniff' + return response + + +@app.context_processor +def override_url_for(): + """ + override the url_for function built into flask + with our own custom implementation that busts the cache correctly when files change + """ + return dict(url_for=url_for_with_cache_bust) + + + +def url_for_with_cache_bust(endpoint, **values): + """ + Add a query parameter based on the hash of the file, this acts as a cache bust + """ + + if endpoint == 'static': + filename = values.get('filename', None) + if filename: + if 'STATIC_FILE_HASH_CACHE' not in current_app.config: + current_app.config['STATIC_FILE_HASH_CACHE'] = dict() + + if filename not in current_app.config['STATIC_FILE_HASH_CACHE']: + filepath = os.path.join(current_app.root_path, endpoint, filename) + #print(filepath) + if os.path.isfile(filepath) and os.access(filepath, os.R_OK): + + with open(filepath, 'rb') as file: + hasher = hashlib.md5() + hasher.update(file.read()) + current_app.config['STATIC_FILE_HASH_CACHE'][filename] = hasher.hexdigest()[-6:] + + values['q'] = current_app.config['STATIC_FILE_HASH_CACHE'][filename] + + return url_for(endpoint, **values) + + + diff --git a/capsulflask/console.py b/capsulflask/console.py index 7d949ed..75e8500 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -217,12 +217,13 @@ def ssh_public_keys(): if method == "POST": parts = re.split(" +", request.form["content"]) if len(parts) > 2 and len(parts[2].strip()) > 0: - name = parts[2] + name = parts[2].strip() else: - name = parts[0] + name = parts[0].strip() else: errors.append("Name is required") - if not re.match(r"^[0-9A-Za-z_@\. -]+$", name): + if not re.match(r"^[0-9A-Za-z_@. -]+$", name): + print(name) errors.append("Name must match \"^[0-9A-Za-z_@. -]+$\"") if method == "POST": @@ -231,7 +232,7 @@ def ssh_public_keys(): errors.append("Content is required") else: content = content.replace("\r", "").replace("\n", "") - if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@\. -]+$", content): + if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@. -]+$", content): errors.append("Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@. -]+$\"") if get_model().ssh_public_key_name_exists(session["account"], name): diff --git a/capsulflask/db.py b/capsulflask/db.py index edb6934..7e93f61 100644 --- a/capsulflask/db.py +++ b/capsulflask/db.py @@ -40,7 +40,7 @@ def init_app(app): hasSchemaVersionTable = False actionWasTaken = False schemaVersion = 0 - desiredSchemaVersion = 4 + desiredSchemaVersion = 5 cursor = connection.cursor() diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 7dd1383..2f793fb 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -175,6 +175,21 @@ class DBModel: self.cursor.fetchall() )) + def payment_session_redirect(self, email, id): + self.cursor.execute("SELECT redirected FROM payment_sessions WHERE email = %s AND id = %s", + (email, id) + ) + row = self.cursor.fetchone() + if row: + self.cursor.execute("UPDATE payment_sessions SET redirected = TRUE WHERE email = %s AND id = %s", + (email, id) + ) + self.connection.commit() + return row[0] + + return None + + def consume_payment_session(self, payment_type, id, dollars): self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type)) row = self.cursor.fetchone() diff --git a/capsulflask/payment.py b/capsulflask/payment.py index 8bbb433..e5f2275 100644 --- a/capsulflask/payment.py +++ b/capsulflask/payment.py @@ -2,9 +2,11 @@ import stripe import json import time import decimal -from time import sleep +import re +from time import sleep from flask import Blueprint +from flask import make_response from flask import request from flask import current_app from flask import session @@ -165,14 +167,46 @@ def stripe_payment(): #return redirect(f"https://checkout.stripe.com/pay/{stripe_checkout_session_id}") + return redirect(f"/payment/stripe/{stripe_checkout_session_id}") + for error in errors: flash(error) - return render_template( - "stripe.html", + return render_template("stripe.html") + +@bp.route("/stripe/") +@account_required +def redirect_to_stripe(stripe_checkout_session_id): + + if stripe_checkout_session_id and not re.match(r"^[a-zA-Z0-9_=-]+$", stripe_checkout_session_id): + stripe_checkout_session_id = '___________' + + response = make_response(render_template( + "stripe.html", stripe_checkout_session_id=stripe_checkout_session_id, stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"] - ) + )) + + if stripe_checkout_session_id is not None: + response.headers['Content-Security-Policy'] = "default-src 'self' https://js.stripe.com" + + return response + +@bp.route("/stripe//json") +@account_required +def stripe_checkout_session_json(stripe_checkout_session_id): + + if stripe_checkout_session_id and not re.match(r"^[a-zA-Z0-9_=-]+$", stripe_checkout_session_id): + stripe_checkout_session_id = '___________' + + has_redirected_already = get_model().payment_session_redirect(session['account'], stripe_checkout_session_id) + + if has_redirected_already is None: + abort(404, "Not Found") + + return jsonify(dict(hasRedirectedAlready=has_redirected_already)) + + def validate_stripe_checkout_session(stripe_checkout_session_id): checkout_session_completed_events = stripe.Event.list( diff --git a/capsulflask/schema_migrations/05_down_stripe_payment_redirect.sql b/capsulflask/schema_migrations/05_down_stripe_payment_redirect.sql new file mode 100644 index 0000000..d555769 --- /dev/null +++ b/capsulflask/schema_migrations/05_down_stripe_payment_redirect.sql @@ -0,0 +1,3 @@ +ALTER TABLE payment_sessions DROP COLUMN redirected; + +UPDATE schemaversion SET version = 4; diff --git a/capsulflask/schema_migrations/05_up_stripe_payment_redirect.sql b/capsulflask/schema_migrations/05_up_stripe_payment_redirect.sql new file mode 100644 index 0000000..caa16d9 --- /dev/null +++ b/capsulflask/schema_migrations/05_up_stripe_payment_redirect.sql @@ -0,0 +1,4 @@ +ALTER TABLE payment_sessions +ADD COLUMN redirected BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE schemaversion SET version = 5; diff --git a/capsulflask/static/create-capsul.js b/capsulflask/static/create-capsul.js new file mode 100644 index 0000000..4b965bd --- /dev/null +++ b/capsulflask/static/create-capsul.js @@ -0,0 +1,9 @@ + +window.addEventListener('DOMContentLoaded', function(event) { + var submitButton = document.getElementById('submit-button'); + var submitButtonClicked = document.getElementById('submit-button-clicked'); + document.getElementById('submit-button').onclick = function() { + submitButton.className = "display-none"; + submitButtonClicked.className = "waiting-pulse"; + } +}); \ No newline at end of file diff --git a/capsulflask/static/pay-with-stripe.js b/capsulflask/static/pay-with-stripe.js new file mode 100644 index 0000000..478d916 --- /dev/null +++ b/capsulflask/static/pay-with-stripe.js @@ -0,0 +1,39 @@ + +window.addEventListener('DOMContentLoaded', function(event) { + + var httpRequest = new XMLHttpRequest(); + httpRequest.onloadend = () => { + if (httpRequest.status < 300) { + try { + responseObject = JSON.parse(httpRequest.responseText); + + if(!responseObject.hasRedirectedAlready) { + Stripe(document.getElementById("stripe_public_key").value) + .redirectToCheckout({ + sessionId: document.getElementById("stripe_checkout_session_id").value, + }) + .then(function(result) { + if (result.error) { + alert("Stripe.redirectToCheckout() failed with: " + result.error.message) + } + }); + } else { + location.href = '/payment/stripe'; + } + + } catch (err) { + alert("could not redirect to stripe because capsul did not return valid json"); + } + } else { + alert("could not redirect to stripe because capsul returned HTTP" + httpRequest.status + ", expected HTTP 200"); + } + }; + + httpRequest.ontimeout = () => { + alert("could not redirect to stripe because capsul timed out"); + }; + + httpRequest.open("GET", "/payment/stripe/"+document.getElementById("stripe_checkout_session_id").value+"/json?q="+String(Math.random()).substring(2, 8)); + httpRequest.timeout = 10000; + httpRequest.send(); +}); \ No newline at end of file diff --git a/capsulflask/static/style.css b/capsulflask/static/style.css index 8353e1c..80ffae3 100644 --- a/capsulflask/static/style.css +++ b/capsulflask/static/style.css @@ -150,11 +150,7 @@ select { -webkit-appearance: none; -moz-appearance: none; appearance: none; - /* - re-generate the following line from the source image with: - echo "background-image: url(data:image/png;base64,$(cat capsulflask/static/dropdown-handle.png | base64 -w 0));" - */ - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA9hAAAPYQGoP6dpAAACfElEQVRYw+2WSWgVQRCGp3tmHmLEuKEEL3owguJBjBcD8eJJiCABT4IrOQiKC6jnoJ6CYBDiQkTxiQiC4nKIntR4E70JUQQ9KahIUAg6m1/FntBvXpYZE6PCFPxUTc90/dW19HuOU0op/4K8GnzcOMN8s8BCsbVZO8hCO1AzQN6EugJa7QCWguvgMB/4f5B8DeqO73vb0JEdwBetVYPnud3Yl0yU003egep3XbclCEInG8C3OE6cMIwc3/e383yXDWuniViDI5J2rXVTFEXpq9gO4Gu6GgSB43neOsyHbNwFpkK+AHWeU3dD3hDHsf06sQO4DZ6lUYVh6CilpEvPgTNpxxYgVmA15i3KuldObZGL8QQ8Hw2geWXbW9RWMECkv8JLEgmiQvQHeLyGw+YCMWwC98hkm5Q1Fdcd8d0POuD8LA8qE/kic+otYHQafM9zgjB8jXkIPGBzMN58o/aAExxkXiblP8ANsJ/9Q+mitr/gxSeUNOHVNBMjfUFJOM0KzJviACJvDPI5QgzOZsnJpKiLYLdNXpcBy1kF1WVOXKnZgDPKU8U8Ct6b5WWgh3q32yk38h2cAichr3upJmmmYyaQmiC4SJiW8AVmJ5Bs9DG+q2SCMjIMjkPcMx6HytHRUtPTYK69TnM6dPcHKSPNtTiK6kZsyNS7OpF/lXOsZEL6qO18u7Zpn2TXeJZe2gn5/cl8qwKzvRF12dR7InkDdkD+NI9fnTcAHD4yd8Wg9EBWzNpL+SYveaEMWJlYjqoyDBuSpGYyBmSEIX9XxJ/6zTt+CeoC2GwaTmrdCfnHor7UFH5oZqN6zd2+D/Lhv/FXbj1oKf/UllLKfy0/ATtM/c/kKrmhAAAAAElFTkSuQmCC); + background-image: url(/static/dropdown-handle.png); background-repeat: no-repeat; background-position: bottom 0.65em right 0.8em; background-size: 0.5em; diff --git a/capsulflask/templates/base.html b/capsulflask/templates/base.html index 21b4af1..9a1b988 100644 --- a/capsulflask/templates/base.html +++ b/capsulflask/templates/base.html @@ -1,6 +1,5 @@ - {% block title %}{% endblock %}{% if self.title() %} - {% endif %}Capsul diff --git a/capsulflask/templates/create-capsul.html b/capsulflask/templates/create-capsul.html index 0cefbde..c736101 100644 --- a/capsulflask/templates/create-capsul.html +++ b/capsulflask/templates/create-capsul.html @@ -67,16 +67,7 @@ - + {% endif %} diff --git a/capsulflask/templates/stripe.html b/capsulflask/templates/stripe.html index 7a73b60..5c10928 100644 --- a/capsulflask/templates/stripe.html +++ b/capsulflask/templates/stripe.html @@ -10,33 +10,30 @@ {% block content %} -
-

PAY WITH STRIPE

-
-
-
-
- - -
-
- -
-
-
+ {% if stripe_checkout_session_id %} - +
+

REDIRECTING...

+
+ + + +{% else %} +
+

PAY WITH STRIPE

+
+
+
+
+ + +
+
+ +
+
+
{% endif %} {% endblock %}