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:
parent
c92032e630
commit
73d087bda7
|
@ -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
|
||||
|
@ -49,6 +49,11 @@ def create_app(config_objects=["grant.settings"]):
|
|||
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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -76,3 +76,6 @@ pyotp==2.2.7
|
|||
|
||||
# JSON formatting
|
||||
animal_case==0.4.1
|
||||
|
||||
# Rate limiting
|
||||
Flask-Limiter==1.0.1
|
|
@ -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):
|
||||
|
|
|
@ -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 = 'I’ve sent it';
|
||||
onOk = this.confirmSend;
|
||||
|
|
|
@ -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>{' '}
|
||||
|
|
|
@ -51,9 +51,10 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
|
||||
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<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 && 'You’ve 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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue