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.
This commit is contained in:
forest 2020-05-22 15:20:26 -05:00
parent 5a080fe1c5
commit 672ff49d6d
13 changed files with 202 additions and 65 deletions

View File

@ -2,12 +2,15 @@ import logging
from logging.config import dictConfig as logging_dict_config from logging.config import dictConfig as logging_dict_config
import os import os
import hashlib
import stripe import stripe
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
from flask import Flask from flask import Flask
from flask_mail import Mail from flask_mail import Mail
from flask import render_template from flask import render_template
from flask import url_for
from flask import current_app
from capsulflask import virt_model, cli from capsulflask import virt_model, cli
from capsulflask.btcpay import client as btcpay from capsulflask.btcpay import client as btcpay
@ -45,19 +48,19 @@ app.config.from_mapping(
) )
logging_dict_config({ logging_dict_config({
'version': 1, 'version': 1,
'formatters': {'default': { 'formatters': {'default': {
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
}}, }},
'handlers': {'wsgi': { 'handlers': {'wsgi': {
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'stream': 'ext://flask.logging.wsgi_errors_stream', 'stream': 'ext://flask.logging.wsgi_errors_stream',
'formatter': 'default' 'formatter': 'default'
}}, }},
'root': { 'root': {
'level': app.config['LOG_LEVEL'], 'level': app.config['LOG_LEVEL'],
'handlers': ['wsgi'] 'handlers': ['wsgi']
} }
}) })
# app.logger.critical("critical") # app.logger.critical("critical")
@ -72,9 +75,9 @@ stripe.api_version = app.config['STRIPE_API_VERSION']
app.config['FLASK_MAIL_INSTANCE'] = Mail(app) app.config['FLASK_MAIL_INSTANCE'] = Mail(app)
if app.config['VIRTUALIZATION_MODEL'] == "shell_scripts": if app.config['VIRTUALIZATION_MODEL'] == "shell_scripts":
app.config['VIRTUALIZATION_MODEL'] = virt_model.ShellScriptVirtualization() app.config['VIRTUALIZATION_MODEL'] = virt_model.ShellScriptVirtualization()
else: 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']) 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.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)

View File

@ -217,12 +217,13 @@ def ssh_public_keys():
if method == "POST": if method == "POST":
parts = re.split(" +", request.form["content"]) parts = re.split(" +", request.form["content"])
if len(parts) > 2 and len(parts[2].strip()) > 0: if len(parts) > 2 and len(parts[2].strip()) > 0:
name = parts[2] name = parts[2].strip()
else: else:
name = parts[0] name = parts[0].strip()
else: else:
errors.append("Name is required") 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_@. -]+$\"") errors.append("Name must match \"^[0-9A-Za-z_@. -]+$\"")
if method == "POST": if method == "POST":
@ -231,7 +232,7 @@ def ssh_public_keys():
errors.append("Content is required") errors.append("Content is required")
else: else:
content = content.replace("\r", "").replace("\n", "") 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+/_=@. -]+$\"") errors.append("Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@. -]+$\"")
if get_model().ssh_public_key_name_exists(session["account"], name): if get_model().ssh_public_key_name_exists(session["account"], name):

View File

@ -40,7 +40,7 @@ def init_app(app):
hasSchemaVersionTable = False hasSchemaVersionTable = False
actionWasTaken = False actionWasTaken = False
schemaVersion = 0 schemaVersion = 0
desiredSchemaVersion = 4 desiredSchemaVersion = 5
cursor = connection.cursor() cursor = connection.cursor()

View File

@ -175,6 +175,21 @@ class DBModel:
self.cursor.fetchall() 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): 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)) self.cursor.execute("SELECT email, dollars FROM payment_sessions WHERE id = %s AND type = %s", (id, payment_type))
row = self.cursor.fetchone() row = self.cursor.fetchone()

View File

@ -2,9 +2,11 @@ import stripe
import json import json
import time import time
import decimal import decimal
from time import sleep import re
from time import sleep
from flask import Blueprint from flask import Blueprint
from flask import make_response
from flask import request from flask import request
from flask import current_app from flask import current_app
from flask import session 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"https://checkout.stripe.com/pay/{stripe_checkout_session_id}")
return redirect(f"/payment/stripe/{stripe_checkout_session_id}")
for error in errors: for error in errors:
flash(error) flash(error)
return render_template( return render_template("stripe.html")
"stripe.html",
@bp.route("/stripe/<string:stripe_checkout_session_id>")
@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_checkout_session_id=stripe_checkout_session_id,
stripe_public_key=current_app.config["STRIPE_PUBLISHABLE_KEY"] 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/<string:stripe_checkout_session_id>/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): def validate_stripe_checkout_session(stripe_checkout_session_id):
checkout_session_completed_events = stripe.Event.list( checkout_session_completed_events = stripe.Event.list(

View File

@ -0,0 +1,3 @@
ALTER TABLE payment_sessions DROP COLUMN redirected;
UPDATE schemaversion SET version = 4;

View File

@ -0,0 +1,4 @@
ALTER TABLE payment_sessions
ADD COLUMN redirected BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE schemaversion SET version = 5;

View File

@ -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";
}
});

View File

@ -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();
});

View File

@ -150,11 +150,7 @@ select {
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
/* background-image: url(/static/dropdown-handle.png);
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-repeat: no-repeat; background-repeat: no-repeat;
background-position: bottom 0.65em right 0.8em; background-position: bottom 0.65em right 0.8em;
background-size: 0.5em; background-size: 0.5em;

View File

@ -1,6 +1,5 @@
<html lang="en"> <html lang="en">
<head> <head>
<link href="data:image/gif;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAGAPAABgDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJSWrAEFB/wAoKLY8KyvAuywsxvUsLMbzKivAsScntTEvL8cAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJCOnAC4u0QAnJ7c9LCzF1S4u0f8vL9P/Ly/T/y4u0P8rK8PFJye1JgAAAAAAAAAAAAAAAAAAAAAAAAAAISGnADEyzwAoKLc7LC3H1TAx1P8xMtb/MDHV/y8v1P8vL9P/Li7P/ioqvYsAAAAAAAAAAAAAAAAAAAAAICCjADU10gAqKrg5LzDJ0zQ11/83ONv/Nzjb/zU22f8yMtb/Ly/U/y8v0v8rK8LCAAAAAAAAAAAAAAAAImWuADIBxAApKrE3MDDJ0Tc42v88Pd//PT/g/z0+3/86O93/NTbZ/zAx1f8vL9H/KyvBugAAAAAAAAAAIpG+ACq06wAmrdo1Kn7U0DVB1P8+P+D/QkTk/0NF5P9BQ+P/PD7f/zc42/8xMtb/LS3M+Ckpu24AAAAAH465ACu26AAnqdgyKrXpzi2++P8xiub/QEve/0hK6P9ISuj/REbl/z4/4P83ONv/MDDQ/SoqwJ4mJq8RI6HGAC285QApsdkwLb3qyy/I+f8vyPz/LsP6/zWN6P9HUuT/Skvq/0RF5f89PuD/NTXV/iwswaUmJbEVJye1AC7A4gAruNkuL8XqyTPR+v8z0v3/Ms/8/zDL/P8uxPr/No3p/0NM4P9BQuP/OTrY/i8wxagoKLIXKiq4AAAAAAAtu9ciMcrqwTXY+v822/3/Ndn9/zTW/f8y0fz/MMv8/y7C+v8yh+b/OEHU/jMzx6sqK7QYLS68AAAATQAAAAAAMMfjhzbZ+P034P7/N9/+/zfd/v822/3/NNb9/zLP/P8vyPz/LLr0/yp3z60rH6waLTG3ABQSfAAAAAAAAAAAADPP6cE44f3/OOT+/zjj/v834f7/N93+/zXZ/f8z0v3/L8b3/ymy5q8kq9cbJ6TZABD/3QAAAAAAAAAAAAAAAAA00em7OeT9/zrm//855f7/OOP+/zff/v822/3/MtD4/yy757ImptQdKa/dAByMugAAAAAAAAAAAAAAAAAAAAAAM83iczji9/k66v//Ouf//znk/v834P7/Ndf5/y/D57UordUeK7XdACGauwAAAAAAAAAAAAAAAAAAAAAAAAAAAC7B1hQ01OiiOOL4+znl/f844vz/Ndn3/DHJ6LIrttYgLr7eACSZvwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzyd4AL8HWFTPO4nc00um/M8/pwTDG4n8tu9YbMMXgACanxQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/4EAAP8AAAD+AAAA/AAAAPgAAADwAAAA4AAAAMABAACAAwAAAAcAAAAPAAAAHwAAAD8AAAB/AAAA/wAAgf8AAA==" rel="icon">
<title>{% block title %}{% endblock %}{% if self.title() %} - {% endif %}Capsul</title> <title>{% block title %}{% endblock %}{% if self.title() %} - {% endif %}Capsul</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">

View File

@ -67,16 +67,7 @@
<input id="submit-button" type="submit" value="Create"> <input id="submit-button" type="submit" value="Create">
<span id="submit-button-clicked" class="display-none">..Creating...</span> <span id="submit-button-clicked" class="display-none">..Creating...</span>
</div> </div>
<script> <script src="{{ url_for('static', filename='create-capsul.js') }}"></script>
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";
}
});
</script>
</form> </form>
{% endif %} {% endif %}

View File

@ -10,33 +10,30 @@
{% block content %} {% block content %}
<div class="row third-margin">
<h1>PAY WITH STRIPE</h1>
</div>
<div class="row half-margin">
<form method="post">
<div class="row justify-start">
<label for="dollars">$</label>
<input type="number" id="dollars" name="dollars"></input>
</div>
<div class="row justify-end">
<input type="submit" value="Pay With Stripe">
</div>
</form>
</div>
{% if stripe_checkout_session_id %} {% if stripe_checkout_session_id %}
<script> <div class="row third-margin">
Stripe("{{ stripe_public_key }}") <h1>REDIRECTING...</h1>
.redirectToCheckout({ </div>
sessionId: "{{ stripe_checkout_session_id }}", <input id="stripe_public_key" type="hidden" value="{{ stripe_public_key }}"/>
}) <input id="stripe_checkout_session_id" type="hidden" value="{{ stripe_checkout_session_id }}"/>
.then(function(result) { <script src="{{ url_for('static', filename='pay-with-stripe.js') }}"></script>
if (result.error) { {% else %}
alert("Stripe.redirectToCheckout() failed with: " + result.error.message) <div class="row third-margin">
} <h1>PAY WITH STRIPE</h1>
}); </div>
</script> <div class="row half-margin">
<form method="post">
<div class="row justify-start">
<label for="dollars">$</label>
<input type="number" id="dollars" name="dollars"></input>
</div>
<div class="row justify-end">
<input type="submit" value="Pay With Stripe">
</div>
</form>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}