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 && ( -

-

Add a team member

-
- - - - -
-
- )} + value={address} + onChange={this.handleChangeInviteAddress} + disabled={maxedOut} + /> + + + + ); } @@ -133,7 +134,7 @@ class CreateFlowTeam extends React.Component { }) .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 { }) .catch((err: Error) => { console.error('Failed to remove invite', err); - message.error('Failed to remove invite', 3); + message.error(err.message, 3); }); }; } diff --git a/frontend/client/components/DraftList/index.tsx b/frontend/client/components/DraftList/index.tsx index 612ba551..e95aa6b1 100644 --- a/frontend/client/components/DraftList/index.tsx +++ b/frontend/client/components/DraftList/index.tsx @@ -67,10 +67,10 @@ class DraftList extends React.Component { 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); } }