Captcha squashed commit. Contains:

basic captcha logic. Still must exempt upgraded users

finishing captcha logic for v1. Note requires redis to temporarily stash hostnames, review for storage concerns

the create_app part is weird

added web test

fixing tests

changing icon for captcha insterstitial
This commit is contained in:
Cole Krumbholz 2017-02-05 22:21:28 -05:00
parent 40d21d8490
commit e4ce811f51
12 changed files with 164 additions and 30 deletions

View File

@ -11,4 +11,4 @@ services:
before_script:
- psql -c 'create database formspree_test;' -U postgres
env:
- REDISTOGO_URL=0.0.0.0:6379 REDIS_URL='redis://h:@localhost:6379' RATE_LIMIT='120 per hour' TEST_DATABASE_URL=postgres:///formspree_test NONCE_SECRET='y0ur_n0nc3_s3cr3t' SECRET_KEY='y0ur_s3cr3t_k3y' HASHIDS_SALT=doesntmatter STRIPE_TEST_PUBLISHABLE_KEY=pk_test_XebfAaLvLpHeO2txAgqWgJPf STRIPE_TEST_SECRET_KEY=sk_test_MLGQEdAHgWy4Rces4khaIxuc
- TESTING=true REDISTOGO_URL=0.0.0.0:6379 REDIS_URL='redis://h:@localhost:6379' RATE_LIMIT='120 per hour' TEST_DATABASE_URL=postgres:///formspree_test NONCE_SECRET='y0ur_n0nc3_s3cr3t' SECRET_KEY='y0ur_s3cr3t_k3y' HASHIDS_SALT=doesntmatter STRIPE_TEST_PUBLISHABLE_KEY=pk_test_XebfAaLvLpHeO2txAgqWgJPf STRIPE_TEST_SECRET_KEY=sk_test_MLGQEdAHgWy4Rces4khaIxuc

View File

@ -3,14 +3,20 @@ import urlparse
import requests
import hashlib
import hashids
import uuid
from urlparse import urljoin
from flask import request, g
from formspree import settings
from formspree.app import redis_store
CAPTCHA_URL = 'https://www.google.com/recaptcha/api/siteverify'
CAPTCHA_VAL = 'g-recaptcha-response'
HASH = lambda x, y: hashlib.md5(x.encode('utf-8')+y.encode('utf-8')+settings.NONCE_SECRET).hexdigest()
EXCLUDE_KEYS = {'_gotcha', '_next', '_subject', '_cc', '_format'}
MONTHLY_COUNTER_KEY = 'monthly_{form_id}_{month}'.format
EXCLUDE_KEYS = {'_gotcha', '_next', '_subject', '_cc', '_format', CAPTCHA_VAL, '_host_nonce'}
REDIS_COUNTER_KEY = 'monthly_{form_id}_{month}'.format
REDIS_HOSTNAME_KEY = 'hostname_{nonce}'.format
HASHIDS_CODEC = hashids.Hashids(alphabet='abcdefghijklmnopqrstuvwxyz',
min_length=8,
salt=settings.HASHIDS_SALT)
@ -94,3 +100,31 @@ def sitewide_file_check(url, email):
g.log.warn('Email not found in sitewide file.', contents=res.text[:100])
return False
def verify_captcha(form_data, request):
if not CAPTCHA_VAL in form_data:
return False
r = requests.post(CAPTCHA_URL, data={
'secret': settings.RECAPTCHA_SECRET,
'response': form_data[CAPTCHA_VAL],
'remoteip': request.remote_addr,
}, timeout=2)
return r.ok and r.json().get('success')
def temp_store_hostname(hostname, referrer):
nonce = uuid.uuid4()
key = REDIS_HOSTNAME_KEY(nonce=nonce)
redis_store.set(key, hostname+','+referrer)
redis_store.expire(key, 300000)
return nonce
def get_temp_hostname(nonce):
key = REDIS_HOSTNAME_KEY(nonce=nonce)
value = redis_store.get(key)
if value == None: raise KeyError()
redis_store.delete(key)
return value.split(',')

View File

@ -8,7 +8,7 @@ from flask import url_for, render_template, g
from sqlalchemy.sql.expression import delete
from werkzeug.datastructures import ImmutableMultiDict, \
ImmutableOrderedMultiDict
from helpers import HASH, HASHIDS_CODEC, MONTHLY_COUNTER_KEY, \
from helpers import HASH, HASHIDS_CODEC, REDIS_COUNTER_KEY, \
EXCLUDE_KEYS, http_form_to_dict, referrer_to_path
class Form(DB.Model):
@ -87,6 +87,12 @@ class Form(DB.Model):
.filter(Form.id == self.id)
return by_email.union(by_creation)
@property
def upgraded(self):
upgraded_controllers = [i for i in self.controllers if i.upgraded]
return len(upgraded_controllers) > 0
@classmethod
def get_with_hashid(cls, hashid):
try:
@ -229,14 +235,14 @@ class Form(DB.Model):
def get_monthly_counter(self, basedate=None):
basedate = basedate or datetime.datetime.now()
month = basedate.month
key = MONTHLY_COUNTER_KEY(form_id=self.id, month=month)
key = REDIS_COUNTER_KEY(form_id=self.id, month=month)
counter = redis_store.get(key) or 0
return int(counter)
def increase_monthly_counter(self, basedate=None):
basedate = basedate or datetime.datetime.now()
month = basedate.month
key = MONTHLY_COUNTER_KEY(form_id=self.id, month=month)
key = REDIS_COUNTER_KEY(form_id=self.id, month=month)
redis_store.incr(key)
redis_store.expireat(key, unix_time_for_12_months_from_now(basedate))

View File

@ -15,6 +15,7 @@ from formspree.app import DB
from formspree.utils import request_wants_json, jsonerror, IS_VALID_EMAIL
from helpers import ordered_storage, referrer_to_path, remove_www, \
referrer_to_baseurl, sitewide_file_check, \
verify_captcha, temp_store_hostname, get_temp_hostname, \
HASH, EXCLUDE_KEYS
from models import Form, Submission
@ -42,7 +43,13 @@ def send(email_or_string):
title='Form should POST',
text='Make sure your form has the <span class="code"><strong>method="POST"</strong></span> attribute'), 405
host = referrer_to_path(request.referrer)
received_data = request.form or request.get_json() or {}
try:
# Get stored hostname from redis (from captcha)
host, referrer = get_temp_hostname(received_data['_host_nonce'])
except KeyError:
host, referrer = referrer_to_path(request.referrer), request.referrer
if not host or host == 'www.google.com':
if request_wants_json():
return jsonerror(400, {'error': "Invalid \"Referrer\" header"})
@ -130,9 +137,22 @@ def send(email_or_string):
# If form exists and is confirmed, send email
# otherwise send a confirmation email
received_data = request.form or request.get_json() or {}
if form.confirmed:
status = form.send(received_data, request.referrer)
captcha_verified = verify_captcha(received_data, request)
needs_captcha = not (request_wants_json() or
form.upgraded or
captcha_verified or
settings.TESTING)
if needs_captcha:
data_copy = received_data.copy()
# Temporarily store hostname in redis while doing captcha
data_copy['_host_nonce'] = temp_store_hostname(form.host, request.referrer)
action = urljoin(settings.API_ROOT, email_or_string)
return render_template('forms/captcha.html',
data=data_copy,
action=action)
status = form.send(received_data, referrer)
else:
status = form.send_confirmation()
@ -149,7 +169,7 @@ def send(email_or_string):
return render_template(
'error.html',
title='Can\'t send an empty form',
text=u'<p>Make sure you have placed the <a href="http://www.w3schools.com/tags/att_input_name.asp" target="_blank"><code>"name"</code> attribute</a> in all your form elements. Also, to prevent empty form submissions, take a look at the <a href="http://www.w3schools.com/tags/att_input_required.asp" target="_blank"><code>"required"</code> property</a>.</p><p>This error also happens when you have an <code>"enctype"</code> attribute set in your <code>&lt;form&gt;</code>, so make sure you don\'t.</p><p><a href="{}">Return to form</a></p>'.format(request.referrer)
text=u'<p>Make sure you have placed the <a href="http://www.w3schools.com/tags/att_input_name.asp" target="_blank"><code>"name"</code> attribute</a> in all your form elements. Also, to prevent empty form submissions, take a look at the <a href="http://www.w3schools.com/tags/att_input_required.asp" target="_blank"><code>"required"</code> property</a>.</p><p>This error also happens when you have an <code>"enctype"</code> attribute set in your <code>&lt;form&gt;</code>, so make sure you don\'t.</p><p><a href="{}">Return to form</a></p>'.format(referrer)
), 400
elif status['code'] == Form.STATUS_CONFIRMATION_SENT or \
status['code'] == Form.STATUS_CONFIRMATION_DUPLICATED:
@ -193,14 +213,8 @@ def resend_confirmation(email):
g.log = g.log.bind(email=email, host=request.form.get('host'))
g.log.info('Resending confirmation.')
# the first thing to do is to check the captcha
r = requests.post('https://www.google.com/recaptcha/api/siteverify', data={
'secret': settings.RECAPTCHA_SECRET,
'response': request.form['g-recaptcha-response'],
'remoteip': request.remote_addr
})
if r.ok and r.json().get('success'):
# then proceed to check if this email is listed on SendGrid's bounces
if verify_captcha(request.form, request):
# check if this email is listed on SendGrid's bounces
r = requests.get('https://api.sendgrid.com/api/bounces.get.json',
params={
'email': email,
@ -255,14 +269,8 @@ def unblock_email(email):
g.log = g.log.bind(email=email)
g.log.info('Unblocking email on SendGrid.')
# check the captcha
r = requests.post('https://www.google.com/recaptcha/api/siteverify', data={
'secret': settings.RECAPTCHA_SECRET,
'response': request.form['g-recaptcha-response'],
'remoteip': request.remote_addr
})
if r.ok and r.json().get('success'):
# then proceed to clear the bounce from SendGrid
if verify_captcha(request.form, request):
# clear the bounce from SendGrid
r = requests.post(
'https://api.sendgrid.com/api/bounces.delete.json',
data={

View File

@ -8,7 +8,7 @@ if DEBUG:
SQLALCHEMY_ECHO = True
TESTING = os.getenv('TESTING') in ['True', 'true', '1', 'yes']
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI') or os.getenv('DATABASE_URL')
LOG_LEVEL = os.getenv('LOG_LEVEL') or 'debug'
@ -18,7 +18,7 @@ HASHIDS_SALT = os.getenv('HASHIDS_SALT')
MONTHLY_SUBMISSIONS_LIMIT = int(os.getenv('MONTHLY_SUBMISSIONS_LIMIT') or 1000)
ARCHIVED_SUBMISSIONS_LIMIT = int(os.getenv('ARCHIVED_SUBMISSIONS_LIMIT') or 100)
REDIS_URL = os.getenv('REDISTOGO_URL') or os.getenv('REDISCLOUD_URL')
REDIS_URL = os.getenv('REDISTOGO_URL') or os.getenv('REDISCLOUD_URL') or 'redis://localhost:6379'
CDN_URL = os.getenv('CDN_URL')

View File

@ -0,0 +1,58 @@
{% extends 'layouts/message.html' %}
{% block head_scripts %}
<script type="text/javascript">
var success = function(response) {
var form = document.querySelector('#passthrough');
form.submit();
};
var onloadCallback = function() {
var form = document.querySelector('#passthrough');
var data = {{ data|tojson|safe }};
for (var key in data) {
if (data.hasOwnProperty(key)) {
var input = document.createElement('input');
input.setAttribute('name', key);
input.setAttribute('value', data[key]);
input.setAttribute('style', 'display:none');
form.appendChild(input);
}
}
grecaptcha.render('recaptcha', {
'sitekey' : {{ config.RECAPTCHA_KEY|tojson|safe }},
'callback' : success
});
};
</script>
{% endblock head_scripts %}
{% block base %}
<div class="container narrow card">
<div class="container" id="header">
<div class="col-1-1 header success">
<i class="ion ion-email"></i>
</div>
</div>
<div class="container">
<div class="col-1-1">
<h1>Almost there</h1>
<p>Please help us fight spam by following the directions below:</p>
<form action="{{ action }}" method="POST" id="passthrough">
<div id="recaptcha" style="text-align:center; display:inline-block"></div>
</form>
</div>
</div>
</div>
{% endblock base %}
{% block tail_scripts %}
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit"
async defer>
</script>
{% endblock tail_scripts %}

View File

@ -18,6 +18,7 @@ m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
ga('create', 'UA-74724777-1', 'auto');
ga('send', 'pageview');
</script>
{% block head_scripts %}{% endblock %}
</head>
<body class="{% block bodyclass %}{% endblock %}" id="{% block bodyid %}card{% endblock %}">
@ -73,6 +74,8 @@ ga('send', 'pageview');
{% block footer %}{% endblock %}
</div>
{% block tail_scripts %}{% endblock %}
<script src="//cdnjs.cloudflare.com/ajax/libs/SlickNav/1.0.3/jquery.slicknav.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/toastr.js/2.1.1/toastr.min.js"></script>
<script>

View File

@ -76,6 +76,12 @@ def unix_time_for_12_months_from_now(now=None):
return calendar.timegm(start_of_next_month.utctimetuple())
def unix_time_for_5_min_from_now(now=None):
now = now or datetime.datetime.now()
five_min_later = now + datetime.timedelta(minutes=5)
return calendar.timegm(five_min_later.utctimetuple())
def next_url(referrer=None, next=None):
referrer = referrer if referrer is not None else ''

View File

@ -11,7 +11,7 @@ from flask.ext.migrate import Migrate, MigrateCommand
from formspree import create_app, app
from formspree.app import redis_store
from formspree.forms.helpers import MONTHLY_COUNTER_KEY
from formspree.forms.helpers import REDIS_COUNTER_KEY
from formspree.forms.models import Form
forms_app = create_app()
@ -82,7 +82,7 @@ def monthly_counters(email=None, host=None, id=None, month=datetime.date.today()
return 1
for form in query:
nsubmissions = redis_store.get(MONTHLY_COUNTER_KEY(form_id=form.id, month=month)) or 0
nsubmissions = redis_store.get(REDIS_COUNTER_KEY(form_id=form.id, month=month)) or 0
print '%s submissions for %s' % (nsubmissions, form)

7
web/test-heroku.html Normal file
View File

@ -0,0 +1,7 @@
<html>
<form action="https://formspree-test.herokuapp.com/test@formspree.io" method="POST">
<input type="text" name="email" placeholder="email" />
<input type="text" name="message" placeholder="message" />
<input type="submit"/>
</form>
</html>

8
web/test.html Normal file
View File

@ -0,0 +1,8 @@
<html>
<form action="http://localhost:5000/test@formspree.io" method="POST">
<input type="text" name="email" placeholder="email" />
<input type="text" name="message" placeholder="message" />
<input type="hidden" name="_next" value="http://localhost:8000/thanks.html"/>
<input type="submit"/>
</form>
</html>

4
web/thanks.html Normal file
View File

@ -0,0 +1,4 @@
<html>
<h1>Thanks!</h1>
<a href="test.html">Back home</a>
</html>