Index: appengine/monorail/framework/actionlimit.py |
diff --git a/appengine/monorail/framework/actionlimit.py b/appengine/monorail/framework/actionlimit.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..b994c1fbf2501a3270a6f0baa8ca10922b60cabf |
--- /dev/null |
+++ b/appengine/monorail/framework/actionlimit.py |
@@ -0,0 +1,227 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered by a BSD-style |
+# license that can be found in the LICENSE file or at |
+# https://developers.google.com/open-source/licenses/bsd |
+ |
+"""A set of functions to test action limits. |
+ |
+Action limits help prevent an individual user from abusing the system |
+by performing an excessive number of operations. E.g., creating |
+thousands of projects. |
+ |
+If the user reaches a soft limit within a given time period, the |
+servlets will start demanding that the user solve a CAPTCHA. |
+ |
+If the user reaches a hard limit within a given time period, any further |
+requests to perform that type of action will fail. |
+ |
+When the user reaches a lifetime limit, they are shown an error page. |
+We can increase the lifetime limit for individual users who contact us. |
+""" |
+ |
+import logging |
+import time |
+ |
+from framework import framework_constants |
+from proto import user_pb2 |
+ |
+ |
+# Action types |
+PROJECT_CREATION = 1 |
+ISSUE_COMMENT = 2 |
+ISSUE_ATTACHMENT = 3 |
+ISSUE_BULK_EDIT = 4 |
+FLAG_SPAM = 5 |
+API_REQUEST = 6 |
+ |
+ACTION_TYPE_NAMES = { |
+ 'project_creation': PROJECT_CREATION, |
+ 'issue_comment': ISSUE_COMMENT, |
+ 'issue_attachment': ISSUE_ATTACHMENT, |
+ 'issue_bulk_edit': ISSUE_BULK_EDIT, |
+ 'flag_spam': FLAG_SPAM, |
+ 'api_request': API_REQUEST, |
+ } |
+ |
+# Action Limit definitions |
+# {action_type: (period, soft_limit, hard_limit, life_max),...} |
+ACTION_LIMITS = { |
+ PROJECT_CREATION: (framework_constants.SECS_PER_DAY, 2, 5, 25), |
+ ISSUE_COMMENT: (framework_constants.SECS_PER_DAY / 4, 5, 100, 10000), |
+ ISSUE_ATTACHMENT: (framework_constants.SECS_PER_DAY, 25, 100, 1000), |
+ ISSUE_BULK_EDIT: (framework_constants.SECS_PER_DAY, 100, 500, 10000), |
+ FLAG_SPAM: (framework_constants.SECS_PER_DAY, 100, 100, 10000), |
+ API_REQUEST: (framework_constants.SECS_PER_DAY, 100000, 100000, 10000000), |
+ } |
+ |
+ |
+# Determine scaling of CAPTCHA frequency. |
+MAX_SOFT_LIMITS = max([ACTION_LIMITS[key][2] - ACTION_LIMITS[key][1] |
+ for key in ACTION_LIMITS]) |
+SQUARES = {i**2 for i in range(1, MAX_SOFT_LIMITS)} |
+SQUARES.add(1) |
+ |
+ |
+def NeedCaptcha(user, action_type, now=None, skip_lifetime_check=False): |
+ """Check that the user is under the limit on a given action. |
+ |
+ Args: |
+ user: instance of user_pb2.User. |
+ action_type: int action type. |
+ now: int time in millis. Defaults to int(time.time()). Used for testing. |
+ skip_lifetime_check: No limit for lifetime actions. |
+ |
+ Raises: |
+ ExcessiveActivityException: when user is over hard or lifetime limits. |
+ |
+ Returns: |
+ False if user is under the soft-limit. True if user is over the |
+ soft-limit, but under the hard and lifetime limits. |
+ """ |
+ if not user: # Anything that can be done by anon users (which is not |
+ return False # much) can be done any number of times w/o CAPTCHA. |
+ if not now: |
+ now = int(time.time()) |
+ |
+ period, soft, hard, life_max = ACTION_LIMITS[action_type] |
+ actionlimit_pb = GetLimitPB(user, action_type) |
+ |
+ # First, users with no action limits recorded must be below limits. |
+ # And, users that we explicitly trust as non-abusers are allowed to take |
+ # and unlimited number of actions. And, site admins are trusted non-abusers. |
+ if (not actionlimit_pb or user.ignore_action_limits or |
+ user.is_site_admin): |
+ return False |
+ |
+ # Second, check if user has reached lifetime limit. |
+ if actionlimit_pb.lifetime_limit: |
+ life_max = actionlimit_pb.lifetime_limit |
+ if actionlimit_pb.period_soft_limit: |
+ soft = actionlimit_pb.period_soft_limit |
+ if actionlimit_pb.period_hard_limit: |
+ hard = actionlimit_pb.period_hard_limit |
+ if (not skip_lifetime_check and life_max is not None |
+ and actionlimit_pb.lifetime_count >= life_max): |
+ raise ExcessiveActivityException() |
+ |
+ # Third, if user can begin a new time period, they are free to go ahead. |
+ if now - actionlimit_pb.reset_timestamp > period: |
+ return False |
+ |
+ # Fourth, check for hard rate limits. |
+ if hard is not None and actionlimit_pb.recent_count >= hard: |
+ raise ExcessiveActivityException() |
+ |
+ # Finally, check the soft limit in this time period. |
+ action_limit = False |
+ if soft is not None: |
+ recent_count = actionlimit_pb.recent_count |
+ if recent_count == soft: |
+ action_limit = True |
+ elif recent_count > soft: |
+ remaining_soft = hard - recent_count |
+ if remaining_soft in SQUARES: |
+ action_limit = True |
+ |
+ if action_limit: |
+ logging.info('soft limit captcha: %d', recent_count) |
+ return action_limit |
+ |
+ |
+def GetLimitPB(user, action_type): |
+ """Return the apporiate action limit PB part of the given User PB.""" |
+ if action_type == PROJECT_CREATION: |
+ if not user.project_creation_limit: |
+ user.project_creation_limit = user_pb2.ActionLimit() |
+ return user.project_creation_limit |
+ elif action_type == ISSUE_COMMENT: |
+ if not user.issue_comment_limit: |
+ user.issue_comment_limit = user_pb2.ActionLimit() |
+ return user.issue_comment_limit |
+ elif action_type == ISSUE_ATTACHMENT: |
+ if not user.issue_attachment_limit: |
+ user.issue_attachment_limit = user_pb2.ActionLimit() |
+ return user.issue_attachment_limit |
+ elif action_type == ISSUE_BULK_EDIT: |
+ if not user.issue_bulk_edit_limit: |
+ user.issue_bulk_edit_limit = user_pb2.ActionLimit() |
+ return user.issue_bulk_edit_limit |
+ elif action_type == FLAG_SPAM: |
+ if not user.flag_spam_limit: |
+ user.flag_spam_limit = user_pb2.ActionLimit() |
+ return user.flag_spam_limit |
+ elif action_type == API_REQUEST: |
+ if not user.api_request_limit: |
+ user.api_request_limit = user_pb2.ActionLimit() |
+ return user.api_request_limit |
+ raise Exception('unexpected action type %r' % action_type) |
+ |
+ |
+def ResetRecentActions(user, action_type): |
+ """Reset the recent counter for an action. |
+ |
+ Args: |
+ user: instance of user_pb2.User. |
+ action_type: int action type. |
+ """ |
+ al = GetLimitPB(user, action_type) |
+ al.recent_count = 0 |
+ al.reset_timestamp = 0 |
+ |
+ |
+def CountAction(user, action_type, delta=1, now=int(time.time())): |
+ """Reset recent counter if eligible, then increment recent and lifetime. |
+ |
+ Args: |
+ user: instance of user_pb2.User. |
+ action_type: int action type. |
+ delta: int number to increment count by. |
+ now: int time in millis. Defaults to int(time.time()). Used for testing. |
+ """ |
+ al = GetLimitPB(user, action_type) |
+ period = ACTION_LIMITS[action_type][0] |
+ |
+ if now - al.reset_timestamp > period: |
+ al.reset_timestamp = now |
+ al.recent_count = 0 |
+ |
+ al.recent_count = al.recent_count + delta |
+ al.lifetime_count = al.lifetime_count + delta |
+ |
+ |
+def CustomizeLimit(user, action_type, soft_limit, hard_limit, lifetime_limit): |
+ """Set custom action limits for a user. |
+ |
+ The recent counters are reset to zero, so the user will not run into |
+ a hard limit. |
+ |
+ Args: |
+ user: instance of user_pb2.User. |
+ action_type: int action type. |
+ soft_limit: soft limit of period. |
+ hard_limit: hard limit of period. |
+ lifetime_limit: lifetime limit. |
+ """ |
+ al = GetLimitPB(user, action_type) |
+ al.lifetime_limit = lifetime_limit |
+ al.period_soft_limit = soft_limit |
+ al.period_hard_limit = hard_limit |
+ |
+ # The mutator will mark the ActionLimit as present, but does not |
+ # necessarily *initialize* the protobuf. We need to ensure that the |
+ # lifetime_count is set (a required field). Additional required |
+ # fields will be set below. |
+ if not al.lifetime_count: |
+ al.lifetime_count = 0 |
+ |
+ # Clear the recent counters so the user will not hit the period limit. |
+ al.recent_count = 0 |
+ al.reset_timestamp = 0 |
+ |
+ |
+class Error(Exception): |
+ """Base exception class for this package.""" |
+ |
+ |
+class ExcessiveActivityException(Error): |
+ """No user with the specified name exists.""" |