From 234a7b88f9e6928e04adb25e96b49701be41eea0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 27 Jul 2017 12:22:11 +0530 Subject: [PATCH] Server: Add an option to ban IP addresses if there are too many failed login attempts --- src/calibre/srv/auth.py | 52 ++++++++++++++++++++++++++++++-- src/calibre/srv/errors.py | 4 +-- src/calibre/srv/handler.py | 3 +- src/calibre/srv/http_response.py | 5 +-- src/calibre/srv/opts.py | 8 +++++ src/calibre/srv/tests/auth.py | 28 +++++++++++++++-- 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/calibre/srv/auth.py b/src/calibre/srv/auth.py index d83dbbbedc..51f884e28f 100644 --- a/src/calibre/srv/auth.py +++ b/src/calibre/srv/auth.py @@ -7,11 +7,12 @@ __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' import binascii, os, random, struct, base64, httplib +from collections import OrderedDict from hashlib import md5, sha256 from itertools import permutations from threading import Lock -from calibre.srv.errors import HTTPAuthRequired, HTTPSimpleResponse +from calibre.srv.errors import HTTPAuthRequired, HTTPSimpleResponse, HTTPForbidden from calibre.srv.http_request import parse_uri from calibre.srv.utils import parse_http_dict, encode_path from calibre.utils.monotonic import monotonic @@ -20,6 +21,45 @@ MAX_AGE_SECONDS = 3600 nonce_counter, nonce_counter_lock = 0, Lock() +class BanList(object): + + def __init__(self, ban_time_in_minutes=0, max_failures_before_ban=5): + self.interval = max(0, ban_time_in_minutes) * 60 + self.max_failures_before_ban = max(0, max_failures_before_ban) + if not self.interval or not self.max_failures_before_ban: + self.is_banned = lambda *a: False + self.failed = lambda *a: None + else: + self.items = OrderedDict() + self.lock = Lock() + + def is_banned(self, key): + with self.lock: + x = self.items.get(key) + if x is None: + return False + previous_fail, fail_count = x + if fail_count < self.max_failures_before_ban: + return False + return monotonic() - previous_fail < self.interval + + def failed(self, key): + with self.lock: + x = self.items.pop(key, None) + fail_count = 0 if x is None else x[1] + now = monotonic() + self.items[key] = now, fail_count + 1 + remove = [] + for old in reversed(self.items): + previous_fail = self.items[old][0] + if now - previous_fail > self.interval: + remove.append(old) + else: + break + for r in remove: + self.items.pop(r, None) + + def as_bytestring(x): if not isinstance(x, bytes): x = x.encode('utf-8') @@ -195,8 +235,11 @@ class AuthController(object): ''' ANDROID_COOKIE = 'android_workaround' - def __init__(self, user_credentials=None, prefer_basic_auth=False, realm='calibre', max_age_seconds=MAX_AGE_SECONDS, log=None): + def __init__(self, + user_credentials=None, prefer_basic_auth=False, realm='calibre', + max_age_seconds=MAX_AGE_SECONDS, log=None, ban_time_in_minutes=0, ban_after=5): self.user_credentials, self.prefer_basic_auth = user_credentials, prefer_basic_auth + self.ban_list = BanList(ban_time_in_minutes=ban_time_in_minutes, max_failures_before_ban=ban_after) self.log = log self.secret = binascii.hexlify(os.urandom(random.randint(20, 30))).decode('ascii') self.max_age_seconds = max_age_seconds @@ -221,6 +264,9 @@ class AuthController(object): return cookie and validate_nonce(self.key_order, cookie, path, self.secret) and not is_nonce_stale(cookie, self.max_age_seconds) def do_http_auth(self, data, endpoint): + ban_key = data.remote_addr, data.forwarded_for + if self.ban_list.is_banned(ban_key): + raise HTTPForbidden('Too many login attempts', log='Too many login attempts from: %s' % (ban_key if data.forwarded_for else data.remote_addr)) auth = data.inheaders.get('Authorization') nonce_is_stale = False log_msg = None @@ -239,6 +285,7 @@ class AuthController(object): data.username = da.username return log_msg = 'Failed login attempt from: %s' % data.remote_addr + self.ban_list.failed(ban_key) elif self.prefer_basic_auth and scheme == 'basic': try: un, pw = base64_decode(rest.strip()).partition(':')[::2] @@ -250,6 +297,7 @@ class AuthController(object): data.username = un return log_msg = 'Failed login attempt from: %s' % data.remote_addr + self.ban_list.failed(ban_key) else: raise HTTPSimpleResponse(httplib.BAD_REQUEST, 'Unsupported authentication method') diff --git a/src/calibre/srv/errors.py b/src/calibre/srv/errors.py index a32017e221..676a69083c 100644 --- a/src/calibre/srv/errors.py +++ b/src/calibre/srv/errors.py @@ -54,8 +54,8 @@ class HTTPBadRequest(HTTPSimpleResponse): class HTTPForbidden(HTTPSimpleResponse): - def __init__(self, http_message='', close_connection=True): - HTTPSimpleResponse.__init__(self, httplib.FORBIDDEN, http_message, close_connection) + def __init__(self, http_message='', close_connection=True, log=None): + HTTPSimpleResponse.__init__(self, httplib.FORBIDDEN, http_message, close_connection, log=log) class BookNotFound(HTTPNotFound): diff --git a/src/calibre/srv/handler.py b/src/calibre/srv/handler.py index 60ffac352c..dfe4e152c8 100644 --- a/src/calibre/srv/handler.py +++ b/src/calibre/srv/handler.py @@ -163,7 +163,8 @@ class Handler(object): if opts.auth: has_ssl = opts.ssl_certfile is not None and opts.ssl_keyfile is not None prefer_basic_auth = {'auto':has_ssl, 'basic':True}.get(opts.auth_mode, False) - self.auth_controller = AuthController(user_credentials=ctx.user_manager, prefer_basic_auth=prefer_basic_auth) + self.auth_controller = AuthController( + user_credentials=ctx.user_manager, prefer_basic_auth=prefer_basic_auth, ban_time_in_minutes=opts.ban_for, ban_after=opts.ban_after) self.router = Router(ctx=ctx, url_prefix=opts.url_prefix, auth_controller=self.auth_controller) for module in SRV_MODULES: module = import_module('calibre.srv.' + module) diff --git a/src/calibre/srv/http_response.py b/src/calibre/srv/http_response.py index 0dde4d5c84..2c244524d8 100644 --- a/src/calibre/srv/http_response.py +++ b/src/calibre/srv/http_response.py @@ -210,7 +210,7 @@ class RequestData(object): # {{{ username = None def __init__(self, method, path, query, inheaders, request_body_file, outheaders, response_protocol, - static_cache, opts, remote_addr, remote_port, is_local_connection, translator_cache, tdir): + static_cache, opts, remote_addr, remote_port, is_local_connection, translator_cache, tdir, forwarded_for): (self.method, self.path, self.query, self.inheaders, self.request_body_file, self.outheaders, self.response_protocol, self.static_cache, self.translator_cache) = ( @@ -218,6 +218,7 @@ class RequestData(object): # {{{ response_protocol, static_cache, translator_cache ) self.remote_addr, self.remote_port, self.is_local_connection = remote_addr, remote_port, is_local_connection + self.forwarded_for = forwarded_for self.opts = opts self.status_code = httplib.OK self.outcookie = Cookie() @@ -432,7 +433,7 @@ class HTTPConnection(HTTPRequest): self.method, self.path, self.query, inheaders, request_body_file, outheaders, self.response_protocol, self.static_cache, self.opts, self.remote_addr, self.remote_port, self.is_local_connection, - self.translator_cache, self.tdir + self.translator_cache, self.tdir, self.forwarded_for ) self.queue_job(self.run_request_handler, data) diff --git a/src/calibre/srv/opts.py b/src/calibre/srv/opts.py index beda31ccca..69c52cdecd 100644 --- a/src/calibre/srv/opts.py +++ b/src/calibre/srv/opts.py @@ -154,6 +154,14 @@ raw_options = ( ' putting this server behind an SSL proxy. Otherwise, leave it as "auto", which' ' will use "basic" if SSL is configured otherwise it will use "digest".'), + _('Ban IP addresses that have repeated login failures'), 'ban_for', 0, + _('Temporarily bans access for IP addresses that have repeated login failures for the' + ' specified number of minutes. Useful to prevent attempts at guessing passwords. If' + ' set to zero, no banning is done.'), + + _('Number of login failures for ban'), 'ban_after', 5, + _('The number of login failures after which an IP address is banned'), + _('Ignored user-defined metadata fields'), 'ignored_fields', None, _('Comma separated list of user-defined metadata fields that will not be displayed' diff --git a/src/calibre/srv/tests/auth.py b/src/calibre/srv/tests/auth.py index a8486829a8..876c00c427 100644 --- a/src/calibre/srv/tests/auth.py +++ b/src/calibre/srv/tests/auth.py @@ -6,7 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' -import httplib, base64, urllib2, subprocess, os, cookielib +import httplib, base64, urllib2, subprocess, os, cookielib, time from collections import namedtuple try: from distutils.spawn import find_executable @@ -41,10 +41,11 @@ def android2(ctx, data): return 'android2' -def router(prefer_basic_auth=False): +def router(prefer_basic_auth=False, ban_for=0, ban_after=5): from calibre.srv.auth import AuthController return Router(globals().itervalues(), auth_controller=AuthController( {'testuser':'testpw', '!@#$%^&*()-=_+':'!@#$%^&*()-=_+'}, + ban_time_in_minutes=ban_for, ban_after=ban_after, prefer_basic_auth=prefer_basic_auth, realm=REALM, max_age_seconds=1)) @@ -240,6 +241,29 @@ class TestAuth(BaseTest): docurl(b'closed', '--digest', '--user', 'testuser:testpw') # }}} + def test_fail_ban(self): # {{{ + ban_for = 0.5/60.0 + r = router(prefer_basic_auth=True, ban_for=ban_for, ban_after=2) + with TestServer(r.dispatch) as server: + r.auth_controller.log = server.log + conn = server.connect() + + def request(un='testuser', pw='testpw'): + conn.request('GET', '/closed', headers={'Authorization': b'Basic ' + base64.standard_b64encode(bytes('%s:%s' % (un, pw)))}) + r = conn.getresponse() + return r.status, r.read() + + warnings = [] + server.loop.log.warn = lambda *args, **kwargs: warnings.append(' '.join(args)) + self.ae((httplib.OK, b'closed'), request()) + self.ae((httplib.UNAUTHORIZED, b''), request('x', 'y')) + self.ae((httplib.UNAUTHORIZED, b''), request('x', 'y')) + self.ae(httplib.FORBIDDEN, request('x', 'y')[0]) + self.ae(httplib.FORBIDDEN, request()[0]) + time.sleep(ban_for * 60 + 0.01) + self.ae((httplib.OK, b'closed'), request()) + # }}} + def test_android_auth_workaround(self): # {{{ 'Test authentication workaround for Android' r = router()