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.
This commit is contained in:
William O'Beirne 2019-03-12 23:35:38 -04:00 committed by Daniel Ternyak
parent c92032e630
commit 73d087bda7
10 changed files with 97 additions and 56 deletions

View File

@ -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": "Youve 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)

View File

@ -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)

View File

@ -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("/<proposal_id>/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("/<proposal_id>/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("/<proposal_id>/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("/<proposal_id>/contributions", methods=["POST"])
@limiter.limit("30/day;10/hour;2/minute")
# TODO add gaurd (minimum, maximum)
@body({
"amount": fields.Str(required=True),

View File

@ -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)

View File

@ -75,4 +75,7 @@ webargs==5.1.2
pyotp==2.2.7
# JSON formatting
animal_case==0.4.1
animal_case==0.4.1
# Rate limiting
Flask-Limiter==1.0.1

View File

@ -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):

View File

@ -106,7 +106,8 @@ export default class ContributionModal extends React.Component<Props, State> {
remain eligible for refunds, you can close this modal, make sure you're
logged in, and don't check the "Contribute without attribution" checkbox.
<br /> <br />
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<Props, State> {
if (error) {
okText = 'Done';
onOk = handleClose;
content = (
<Result
type="error"
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.
`}
/>
);
// 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 = <Result type="error" title={title} description={description} />;
} else {
okText = 'Ive sent it';
onOk = this.confirmSend;

View File

@ -102,8 +102,8 @@ class CreateFinal extends React.Component<Props, State> {
<Link to={`/profile?tab=funded`}>profile's funded tab</Link>.
</p>
<p>
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{' '}
<Link to={`/profile?tab=pending`}>
profile's pending proposals tab
</Link>{' '}

View File

@ -51,9 +51,10 @@ class CreateFlowTeam extends React.Component<Props, State> {
render() {
const { team, invites, address } = this.state;
const inviteError = address && !isValidEmail(address) &&
'That doesnt look like a valid email address';
const inviteDisabled = !!inviteError || !address;
const inviteError =
address && !isValidEmail(address) && 'That doesnt 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<Props, State> {
))}
</div>
)}
{team.length < MAX_TEAM_SIZE && (
<div className="TeamForm-add">
<h3 className="TeamForm-add-title">Add a team member</h3>
<Form className="TeamForm-add-form" onSubmit={this.handleAddSubmit}>
<Form.Item
className="TeamForm-add-form-field"
validateStatus={inviteError ? 'error' : undefined}
help={
inviteError ||
'They will be notified and will have to accept the invitation before being added'
}
>
<Input
className="TeamForm-add-form-field-input"
placeholder="Email address"
size="large"
value={address}
onChange={this.handleChangeInviteAddress}
/>
</Form.Item>
<Button
className="TeamForm-add-form-submit"
type="primary"
disabled={inviteDisabled}
htmlType="submit"
icon="user-add"
<div className="TeamForm-add">
<h3 className="TeamForm-add-title">Add a team member</h3>
<Form className="TeamForm-add-form" onSubmit={this.handleAddSubmit}>
<Form.Item
className="TeamForm-add-form-field"
validateStatus={inviteError ? 'error' : undefined}
help={
inviteError ||
(maxedOut && 'Youve invited the maximum number of teammates') ||
'They will be notified and will have to accept the invitation before being added'
}
>
<Input
className="TeamForm-add-form-field-input"
placeholder="Email address"
size="large"
>
Add
</Button>
</Form>
</div>
)}
value={address}
onChange={this.handleChangeInviteAddress}
disabled={maxedOut}
/>
</Form.Item>
<Button
className="TeamForm-add-form-submit"
type="primary"
disabled={inviteDisabled}
htmlType="submit"
icon="user-add"
size="large"
>
Add
</Button>
</Form>
</div>
</div>
);
}
@ -133,7 +134,7 @@ class CreateFlowTeam extends React.Component<Props, State> {
})
.catch((err: Error) => {
console.error('Failed to send invite', err);
message.error('Failed to send invite', 3);
message.error(err.message, 3);
});
};
@ -146,7 +147,7 @@ class CreateFlowTeam extends React.Component<Props, State> {
})
.catch((err: Error) => {
console.error('Failed to remove invite', err);
message.error('Failed to remove invite', 3);
message.error(err.message, 3);
});
};
}

View File

@ -67,10 +67,10 @@ class DraftList extends React.Component<Props, State> {
this.setState({ deletingId: null });
}
if (deleteDraftError && prevProps.deleteDraftError !== deleteDraftError) {
message.error('Failed to delete draft', 3);
message.error(deleteDraftError, 3);
}
if (createDraftError && prevProps.createDraftError !== createDraftError) {
message.error('Failed to create draft', 3);
message.error(createDraftError, 3);
}
}