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:
parent
8d5e8fc685
commit
3309daa32e
7 changed files with 81 additions and 28 deletions
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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):
|
||||||
|
|
Reference in a new issue