From 73d087bda74fb7e539da35cac49aaa38b621c354 Mon Sep 17 00:00:00 2001
From: William O'Beirne
Date: Tue, 12 Mar 2019 23:35:38 -0400
Subject: [PATCH] Rate limits (#342)
* Implemented rate limits for most POST requests.
* Nicer error message for rate limited contributions
* Use error message for drafts and invites, limit invites on frontend.
---
backend/grant/app.py | 12 ++-
backend/grant/extensions.py | 3 +
backend/grant/proposal/views.py | 13 ++++
backend/grant/user/views.py | 4 +
backend/requirements/prod.txt | 5 +-
backend/tests/config.py | 2 +
.../components/ContributionModal/index.tsx | 31 +++++---
.../client/components/CreateFlow/Final.tsx | 4 +-
.../client/components/CreateFlow/Team.tsx | 75 ++++++++++---------
.../client/components/DraftList/index.tsx | 4 +-
10 files changed, 97 insertions(+), 56 deletions(-)
diff --git a/backend/grant/app.py b/backend/grant/app.py
index d347aae2..259ecc11 100644
--- a/backend/grant/app.py
+++ b/backend/grant/app.py
@@ -2,14 +2,14 @@
"""The app module, containing the app factory function."""
import sentry_sdk
from animal_case import animalify
-from flask import Flask, Response, jsonify
+from flask import Flask, Response, jsonify, request
from flask_cors import CORS
from flask_security import SQLAlchemyUserDatastore
from flask_sslify import SSLify
from sentry_sdk.integrations.flask import FlaskIntegration
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp
-from grant.extensions import bcrypt, migrate, db, ma, security
+from grant.extensions import bcrypt, migrate, db, ma, security, limiter
from grant.settings import SENTRY_RELEASE, ENV
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
from grant.utils.exceptions import ValidationException
@@ -48,7 +48,12 @@ def create_app(config_objects=["grant.settings"]):
return jsonify({"message": error_message}), err.code, headers
else:
return jsonify({"message": error_message}), err.code
-
+
+ @app.errorhandler(429)
+ def handle_limit_error(err):
+ print(f'Rate limited request to {request.method} {request.path} from ip {request.remote_addr}')
+ return jsonify({"message": "You’ve done that too many times, please wait and try again later"}), 429
+
@app.errorhandler(Exception)
def handle_exception(err):
return jsonify({"message": "Something went wrong"}), 500
@@ -86,6 +91,7 @@ def register_extensions(app):
db.init_app(app)
migrate.init_app(app, db)
ma.init_app(app)
+ limiter.init_app(app)
user_datastore = SQLAlchemyUserDatastore(db, user.models.User, user.models.Role)
security.init_app(app, datastore=user_datastore, register_blueprint=False)
diff --git a/backend/grant/extensions.py b/backend/grant/extensions.py
index a00f529a..a919ccc9 100644
--- a/backend/grant/extensions.py
+++ b/backend/grant/extensions.py
@@ -5,9 +5,12 @@ from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_security import Security
from flask_sqlalchemy import SQLAlchemy
+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
bcrypt = Bcrypt()
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()
security = Security()
+limiter = Limiter(key_func=get_remote_address)
diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py
index 89839345..66482646 100644
--- a/backend/grant/proposal/views.py
+++ b/backend/grant/proposal/views.py
@@ -5,6 +5,7 @@ from flask import Blueprint, g, request
from marshmallow import fields, validate
from sqlalchemy import or_
+from grant.extensions import limiter
from grant.comment.models import Comment, comment_schema, comments_schema
from grant.email.send import send_email
from grant.milestone.models import Milestone
@@ -94,6 +95,7 @@ def report_proposal_comment(proposal_id, comment_id):
@blueprint.route("//comments", methods=["POST"])
+@limiter.limit("30/hour;2/minute")
@requires_email_verified_auth
@body({
"comment": fields.Str(required=True),
@@ -174,6 +176,7 @@ def get_proposals(page, filters, search, sort):
@blueprint.route("/drafts", methods=["POST"])
+@limiter.limit("10/hour;3/minute")
@requires_email_verified_auth
@body({
"rfpId": fields.Int(required=False, missing=None)
@@ -351,6 +354,7 @@ def get_proposal_update(proposal_id, update_id):
@blueprint.route("//updates", methods=["POST"])
+@limiter.limit("5/day;1/minute")
@requires_team_member_auth
@body({
"title": fields.Str(required=True),
@@ -380,11 +384,19 @@ def post_proposal_update(proposal_id, title, content):
@blueprint.route("//invite", methods=["POST"])
+@limiter.limit("30/day;10/minute")
@requires_team_member_auth
@body({
"address": fields.Str(required=True),
})
def post_proposal_team_invite(proposal_id, address):
+ existing_invite = ProposalTeamInvite.query.filter_by(
+ proposal_id=proposal_id,
+ address=address
+ ).first()
+ if existing_invite:
+ return {"message": f"You've already invited {address}"}, 400
+
invite = ProposalTeamInvite(
proposal_id=proposal_id,
address=address
@@ -472,6 +484,7 @@ def get_proposal_contribution(proposal_id, contribution_id):
@blueprint.route("//contributions", methods=["POST"])
+@limiter.limit("30/day;10/hour;2/minute")
# TODO add gaurd (minimum, maximum)
@body({
"amount": fields.Str(required=True),
diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py
index 9d510471..72ea3dfe 100644
--- a/backend/grant/user/views.py
+++ b/backend/grant/user/views.py
@@ -3,6 +3,7 @@ from flask import Blueprint, g
from marshmallow import fields
import grant.utils.auth as auth
+from grant.extensions import limiter
from grant.comment.models import Comment, user_comments_schema
from grant.email.models import EmailRecovery
from grant.parser import query, body
@@ -89,6 +90,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
@blueprint.route("/", methods=["POST"])
+@limiter.limit("30/day;5/minute")
@body({
# TODO guard all (valid, minimum, maximum)
"emailAddress": fields.Str(required=True),
@@ -207,6 +209,7 @@ def verify_user_social(service, code):
@blueprint.route("/recover", methods=["POST"])
+@limiter.limit("10/day;2/minute")
@body({
"email": fields.Str(required=True)
})
@@ -239,6 +242,7 @@ def recover_email(code, password):
@blueprint.route("/avatar", methods=["POST"])
+@limiter.limit("20/day;3/minute")
@auth.requires_auth
@body({
"mimetype": fields.Str(required=True)
diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt
index ccbde94f..c25625bd 100644
--- a/backend/requirements/prod.txt
+++ b/backend/requirements/prod.txt
@@ -75,4 +75,7 @@ webargs==5.1.2
pyotp==2.2.7
# JSON formatting
-animal_case==0.4.1
\ No newline at end of file
+animal_case==0.4.1
+
+# Rate limiting
+Flask-Limiter==1.0.1
\ No newline at end of file
diff --git a/backend/tests/config.py b/backend/tests/config.py
index 2b89a4a1..f1b1688d 100644
--- a/backend/tests/config.py
+++ b/backend/tests/config.py
@@ -8,6 +8,7 @@ from grant.task.jobs import ProposalReminder
from grant.user.models import User, SocialMedia, db, Avatar
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.utils.enums import ProposalStatus
+from grant.extensions import limiter
from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests
@@ -17,6 +18,7 @@ class BaseTestConfig(TestCase):
def create_app(self):
app = create_app(['grant.settings', 'tests.settings'])
app.config.from_object('tests.settings')
+ limiter.enabled = False
return app
def setUp(self):
diff --git a/frontend/client/components/ContributionModal/index.tsx b/frontend/client/components/ContributionModal/index.tsx
index 4203331a..c82a8386 100644
--- a/frontend/client/components/ContributionModal/index.tsx
+++ b/frontend/client/components/ContributionModal/index.tsx
@@ -106,7 +106,8 @@ export default class ContributionModal extends React.Component {
remain eligible for refunds, you can close this modal, make sure you're
logged in, and don't check the "Contribute without attribution" checkbox.
- NOTE: The Zcash Foundation is unable to accept donations of more than $5,000 USD worth of ZEC from anonymous users.
+ NOTE: The Zcash Foundation is unable to accept donations of more than $5,000
+ USD worth of ZEC from anonymous users.
>
}
/>
@@ -138,16 +139,24 @@ export default class ContributionModal extends React.Component {
if (error) {
okText = 'Done';
onOk = handleClose;
- content = (
-
- );
+ // This should probably key on non-display text, but oh well.
+ let title;
+ let description;
+ if (error.includes('too many times')) {
+ title = 'Take it easy!';
+ description = `
+ We appreciate your enthusiasm, but you've made too many
+ contributions too fast. Please wait for your other contributions,
+ and try again later.
+ `;
+ } else {
+ title = 'Something went wrong';
+ description = `
+ We were unable to get your contribution started. Please check back
+ soon, we're working to fix the problem as soon as possible.
+ `;
+ }
+ content = ;
} else {
okText = 'I’ve sent it';
onOk = this.confirmSend;
diff --git a/frontend/client/components/CreateFlow/Final.tsx b/frontend/client/components/CreateFlow/Final.tsx
index fe36a4cd..9d5ad601 100644
--- a/frontend/client/components/CreateFlow/Final.tsx
+++ b/frontend/client/components/CreateFlow/Final.tsx
@@ -102,8 +102,8 @@ class CreateFinal extends React.Component {
profile's funded tab.
- Once your payment has been sent and processed with 6 confirmations, you
- will receive an email. Visit your{' '}
+ Once your payment has been sent and processed with 6
+ confirmations, you will receive an email. Visit your{' '}
profile's pending proposals tab
{' '}
diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx
index 065ed624..99d70c61 100644
--- a/frontend/client/components/CreateFlow/Team.tsx
+++ b/frontend/client/components/CreateFlow/Team.tsx
@@ -51,9 +51,10 @@ class CreateFlowTeam extends React.Component {
render() {
const { team, invites, address } = this.state;
- const inviteError = address && !isValidEmail(address) &&
- 'That doesn’t look like a valid email address';
- const inviteDisabled = !!inviteError || !address;
+ const inviteError =
+ address && !isValidEmail(address) && 'That doesn’t look like a valid email address';
+ const maxedOut = invites.length >= MAX_TEAM_SIZE - 1;
+ const inviteDisabled = !!inviteError || !address || maxedOut;
const pendingInvites = invites.filter(inv => inv.accepted === null);
return (
@@ -79,39 +80,39 @@ class CreateFlowTeam extends React.Component {
))}
)}
- {team.length < MAX_TEAM_SIZE && (
-