Add support for reduced initial build count for new possible abusing users

If configured, we now check the IP address of the user signing up and, if they are a possible threat, we further reduce their number of allowed maximum builds to the configured value.
This commit is contained in:
Joseph Schorr 2018-04-20 18:01:05 +03:00
parent 8d5e8fc685
commit 3309daa32e
7 changed files with 81 additions and 28 deletions

View file

@ -502,7 +502,7 @@ class DefaultConfig(ImmutableConfig):
# The size of pages returned by the Docker V2 API. # The size of pages returned by the Docker V2 API.
V2_PAGINATION_SIZE = 50 V2_PAGINATION_SIZE = 50
# If enabled, ensures that API calls are made with the X-Requested-With header # If enabled, ensures that API calls are made with the X-Requested-With header
# when called from a browser. # when called from a browser.
BROWSER_API_CALLS_XHR_ONLY = True BROWSER_API_CALLS_XHR_ONLY = True
@ -510,6 +510,10 @@ class DefaultConfig(ImmutableConfig):
# If set to a non-None integer value, the default number of maximum builds for a namespace. # If set to a non-None integer value, the default number of maximum builds for a namespace.
DEFAULT_NAMESPACE_MAXIMUM_BUILD_COUNT = None DEFAULT_NAMESPACE_MAXIMUM_BUILD_COUNT = None
# If set to a non-None integer value, the default number of maximum builds for a namespace whose
# creator IP is deemed a threat.
THREAT_NAMESPACE_MAXIMUM_BUILD_COUNT = None
# For Billing Support Only: The number of allowed builds on a namespace that has been billed # For Billing Support Only: The number of allowed builds on a namespace that has been billed
# successfully. # successfully.
BILLED_NAMESPACE_MAXIMUM_BUILD_COUNT = None BILLED_NAMESPACE_MAXIMUM_BUILD_COUNT = None

View file

@ -5,22 +5,24 @@ from data.model import (user, team, DataModelException, InvalidOrganizationExcep
InvalidUsernameException, db_transaction, _basequery) InvalidUsernameException, db_transaction, _basequery)
def create_organization(name, email, creating_user, email_required=True): def create_organization(name, email, creating_user, email_required=True, is_possible_abuser=False):
try: with db_transaction():
# Create the org try:
new_org = user.create_user_noverify(name, email, email_required=email_required) # Create the org
new_org.organization = True new_org = user.create_user_noverify(name, email, email_required=email_required,
new_org.save() is_possible_abuser=is_possible_abuser)
new_org.organization = True
new_org.save()
# Create a team for the owners # Create a team for the owners
owners_team = team.create_team('owners', new_org, 'admin') owners_team = team.create_team('owners', new_org, 'admin')
# Add the user who created the org to the owners team # Add the user who created the org to the owners team
team.add_user_to_team(creating_user, owners_team) team.add_user_to_team(creating_user, owners_team)
return new_org return new_org
except InvalidUsernameException as iue: except InvalidUsernameException as iue:
raise InvalidOrganizationException(iue.message) raise InvalidOrganizationException(iue.message)
def get_organization(name): def get_organization(name):

View file

@ -37,12 +37,14 @@ def hash_password(password, salt=None):
salt = salt or bcrypt.gensalt() salt = salt or bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt) return bcrypt.hashpw(password.encode('utf-8'), salt)
def create_user(username, password, email, auto_verify=False, email_required=True, prompts=tuple()): def create_user(username, password, email, auto_verify=False, email_required=True, prompts=tuple(),
is_possible_abuser=False):
""" Creates a regular user, if allowed. """ """ Creates a regular user, if allowed. """
if not validate_password(password): if not validate_password(password):
raise InvalidPasswordException(INVALID_PASSWORD_MESSAGE) raise InvalidPasswordException(INVALID_PASSWORD_MESSAGE)
created = create_user_noverify(username, email, email_required=email_required, prompts=prompts) created = create_user_noverify(username, email, email_required=email_required, prompts=prompts,
is_possible_abuser=is_possible_abuser)
created.password_hash = hash_password(password) created.password_hash = hash_password(password)
created.verified = auto_verify created.verified = auto_verify
created.save() created.save()
@ -50,7 +52,8 @@ def create_user(username, password, email, auto_verify=False, email_required=Tru
return created return created
def create_user_noverify(username, email, email_required=True, prompts=tuple()): def create_user_noverify(username, email, email_required=True, prompts=tuple(),
is_possible_abuser=False):
if email_required: if email_required:
if not validate_email(email): if not validate_email(email):
raise InvalidEmailAddressException('Invalid email address: %s' % email) raise InvalidEmailAddressException('Invalid email address: %s' % email)
@ -82,6 +85,11 @@ def create_user_noverify(username, email, email_required=True, prompts=tuple()):
try: try:
default_expr_s = _convert_to_s(config.app_config['DEFAULT_TAG_EXPIRATION']) default_expr_s = _convert_to_s(config.app_config['DEFAULT_TAG_EXPIRATION'])
default_max_builds = config.app_config.get('DEFAULT_NAMESPACE_MAXIMUM_BUILD_COUNT') default_max_builds = config.app_config.get('DEFAULT_NAMESPACE_MAXIMUM_BUILD_COUNT')
threat_max_builds = config.app_config.get('THREAT_NAMESPACE_MAXIMUM_BUILD_COUNT')
if is_possible_abuser and threat_max_builds is not None:
default_max_builds = threat_max_builds
new_user = User.create(username=username, email=email, removed_tag_expiration_s=default_expr_s, new_user = User.create(username=username, email=email, removed_tag_expiration_s=default_expr_s,
maximum_queued_builds_count=default_max_builds) maximum_queued_builds_count=default_max_builds)
for prompt in prompts: for prompt in prompts:

View file

@ -6,7 +6,8 @@ from flask import request
import features import features
from app import billing as stripe, avatar, all_queues, authentication, namespace_gc_queue from app import (billing as stripe, avatar, all_queues, authentication, namespace_gc_queue,
ip_resolver)
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
related_user_resource, internal_only, require_user_admin, log_action, related_user_resource, internal_only, require_user_admin, log_action,
show_if, path_param, require_scope, require_fresh_login) show_if, path_param, require_scope, require_fresh_login)
@ -111,9 +112,11 @@ class OrganizationList(ApiResource):
if features.MAILING and not org_data.get('email'): if features.MAILING and not org_data.get('email'):
raise request_error(message='Email address is required') raise request_error(message='Email address is required')
is_possible_abuser = ip_resolver.is_ip_possible_threat(request.remote_addr)
try: try:
model.organization.create_organization(org_data['name'], org_data.get('email'), user, model.organization.create_organization(org_data['name'], org_data.get('email'), user,
email_required=features.MAILING) email_required=features.MAILING,
is_possible_abuser=is_possible_abuser)
return 'Created', 201 return 'Created', 201
except model.DataModelException as ex: except model.DataModelException as ex:
raise request_error(exception=ex) raise request_error(exception=ex)

View file

@ -12,7 +12,7 @@ from peewee import IntegrityError
import features import features
from app import (app, billing as stripe, authentication, avatar, user_analytics, all_queues, from app import (app, billing as stripe, authentication, avatar, user_analytics, all_queues,
oauth_login, namespace_gc_queue) oauth_login, namespace_gc_queue, ip_resolver)
from auth import scopes from auth import scopes
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
@ -455,12 +455,14 @@ class User(ApiResource):
'message': 'Are you a bot? If not, please revalidate the captcha.' 'message': 'Are you a bot? If not, please revalidate the captcha.'
}, 400 }, 400
is_possible_abuser = ip_resolver.is_ip_possible_threat(request.remote_addr)
try: try:
prompts = model.user.get_default_user_prompts(features) prompts = model.user.get_default_user_prompts(features)
new_user = model.user.create_user(user_data['username'], user_data['password'], new_user = model.user.create_user(user_data['username'], user_data['password'],
user_data.get('email'), user_data.get('email'),
auto_verify=not features.MAILING, auto_verify=not features.MAILING,
email_required=features.MAILING, email_required=features.MAILING,
is_possible_abuser=is_possible_abuser,
prompts=prompts) prompts=prompts)
email_address_confirmed = handle_invite_code(invite_code, new_user) email_address_confirmed = handle_invite_code(invite_code, new_user)

View file

@ -73,6 +73,7 @@ INTERNAL_ONLY_PROPERTIES = {
'SENTRY_PUBLIC_DSN', 'SENTRY_PUBLIC_DSN',
'BILLED_NAMESPACE_MAXIMUM_BUILD_COUNT', 'BILLED_NAMESPACE_MAXIMUM_BUILD_COUNT',
'THREAT_NAMESPACE_MAXIMUM_BUILD_COUNT',
'SECURITY_SCANNER_ENDPOINT_BATCH', 'SECURITY_SCANNER_ENDPOINT_BATCH',
'SECURITY_SCANNER_API_TIMEOUT_SECONDS', 'SECURITY_SCANNER_API_TIMEOUT_SECONDS',

View file

@ -1,16 +1,16 @@
import logging import logging
import json import json
import requests
from collections import namedtuple, defaultdict
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from six import add_metaclass from six import add_metaclass
from cachetools import ttl_cache, lru_cache from cachetools import ttl_cache, lru_cache
from collections import namedtuple, defaultdict
from netaddr import IPNetwork, IPAddress, IPSet, AddrFormatError from netaddr import IPNetwork, IPAddress, IPSet, AddrFormatError
import geoip2.database import geoip2.database
import geoip2.errors import geoip2.errors
import requests
from util.abchelpers import nooper from util.abchelpers import nooper
@ -44,6 +44,13 @@ class IPResolverInterface(object):
""" """
pass pass
@abstractmethod
def is_ip_possible_threat(self, ip_address):
""" Attempts to return whether the given IP address is a possible abuser or spammer.
Returns False if the IP address information could not be looked up.
"""
pass
@nooper @nooper
class NoopIPResolver(IPResolverInterface): class NoopIPResolver(IPResolverInterface):
@ -56,14 +63,39 @@ class IPResolver(IPResolverInterface):
self.app = app self.app = app
self.geoip_db = geoip2.database.Reader('util/ipresolver/GeoLite2-Country.mmdb') self.geoip_db = geoip2.database.Reader('util/ipresolver/GeoLite2-Country.mmdb')
@ttl_cache(maxsize=100, ttl=600)
def is_ip_possible_threat(self, ip_address):
if self.app.config.get('THREAT_NAMESPACE_MAXIMUM_BUILD_COUNT') is None:
return False
if not ip_address:
return False
try:
logger.debug('Requesting IP data for IP %s', ip_address)
r = requests.get('https://api.ipdata.co/%s/en' % ip_address, timeout=1)
if r.status_code != 200:
logger.debug('Got non-200 response for IP %s: %s', ip_address, r.status_code)
return False
logger.debug('Got IP data for IP %s: %s => %s', ip_address, r.status_code, r.json)
threat_data = r.json.get('threat', {})
return threat_data.get('is_threat', False) or threat_data.get('is_bogon', False)
except requests.RequestException:
logger.exception('Got exception when trying to lookup IP Address')
except ValueError:
logger.exception('Got exception when trying to lookup IP Address')
return False
def resolve_ip(self, ip_address): def resolve_ip(self, ip_address):
""" Attempts to return resolved information about the specified IP Address. If such an attempt fails, """ Attempts to return resolved information about the specified IP Address. If such an attempt
returns None. fails, returns None.
""" """
location_function = self._get_location_function() location_function = self._get_location_function()
if not ip_address or not location_function: if not ip_address or not location_function:
return None return None
return location_function(ip_address) return location_function(ip_address)
def _get_aws_ip_ranges(self): def _get_aws_ip_ranges(self):
@ -79,7 +111,7 @@ class IPResolver(IPResolverInterface):
except TypeError: except TypeError:
logger.exception('Could not load AWS IP Ranges') logger.exception('Could not load AWS IP Ranges')
return None return None
@ttl_cache(maxsize=1, ttl=600) @ttl_cache(maxsize=1, ttl=600)
def _get_location_function(self): def _get_location_function(self):
aws_ip_range_json = self._get_aws_ip_ranges() aws_ip_range_json = self._get_aws_ip_ranges()
@ -88,7 +120,8 @@ class IPResolver(IPResolverInterface):
sync_token = aws_ip_range_json['syncToken'] sync_token = aws_ip_range_json['syncToken']
all_amazon, regions, services = IPResolver._parse_amazon_ranges(aws_ip_range_json) all_amazon, regions, services = IPResolver._parse_amazon_ranges(aws_ip_range_json)
return IPResolver._build_location_function(sync_token, all_amazon, regions, services, self.geoip_db) return IPResolver._build_location_function(sync_token, all_amazon, regions, services,
self.geoip_db)
@staticmethod @staticmethod
def _build_location_function(sync_token, all_amazon, regions, country, country_db): def _build_location_function(sync_token, all_amazon, regions, country, country_db):