Mercurial > p > roundup > code
diff test/test_liveserver.py @ 7556:273c8c2b5042
fix(api): - issue2551063 - Rest/Xmlrpc interfaces needs failed login protection.
Failed API login rate limiting with expiring lockout added.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Wed, 19 Jul 2023 20:37:45 -0400 |
| parents | 765222ef4cec |
| children | 7b481ec7f169 |
line wrap: on
line diff
--- a/test/test_liveserver.py Tue Jul 18 23:18:09 2023 -0400 +++ b/test/test_liveserver.py Wed Jul 19 20:37:45 2023 -0400 @@ -7,6 +7,7 @@ from roundup.cgi.wsgi_handler import RequestDispatcher from .wsgi_liveserver import LiveServerTestCase from . import db_test_base +from time import sleep from wsgiref.validate import validator @@ -616,55 +617,6 @@ self.assertEqual(f.status_code, 404) - def DISABLEtest_rest_login_rate_limit(self): - """login rate limit applies to api endpoints. Only failure - logins count though. So log in 10 times in a row - to verify that valid username/passwords aren't limited. - - FIXME: client.py does not implement this. Also need a live - server instance that has - - cls.db.config.WEB_LOGIN_ATTEMPTS_MIN = 4 - - not 0. - """ - - for i in range(10): - # use basic auth for rest endpoint - - f = requests.options(self.url_base() + '/rest/data', - auth=('admin', 'sekrit'), - headers = {'content-type': "", - 'Origin': "http://localhost:9001",} - ) - print(f.status_code) - print(f.headers) - - self.assertEqual(f.status_code, 204) - expected = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', - 'Allow': 'OPTIONS, GET', - 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH', - 'Access-Control-Allow-Credentials': 'true', - } - - for i in range(10): - # use basic auth for rest endpoint - - f = requests.options(self.url_base() + '/rest/data', - auth=('admin', 'ekrit'), - headers = {'content-type': "", - 'Origin': "http://localhost:9001",} - ) - print(i, f.status_code) - print(f.headers) - print(f.text) - - if (i < 3): # assuming limit is 4. - self.assertEqual(f.status_code, 401) - else: - self.assertEqual(f.status_code, 429) - def test_ims(self): ''' retreive the user_utils.js file with old and new if-modified-since timestamps. @@ -1361,3 +1313,204 @@ # use a ts: search as well so it only works on postgres_fts indexer f = requests.get(self.url_base() + "?@search_text=ts:RESULT") self.assertIn("foo bar RESULT", f.text) + +class TestApiRateLogin(WsgiSetup): + """Class to run test in BaseTestCases with the cache_tracker + feature flag enabled when starting the wsgi server + """ + + backend = 'sqlite' + + @classmethod + def setup_class(cls): + '''All tests in this class use the same roundup instance. + This instance persists across all tests. + Create the tracker dir here so that it is ready for the + create_app() method to be called. + + cribbed from WsgiSetup::setup_class + ''' + + # tests in this class. + # set up and open a tracker + cls.instance = db_test_base.setupTracker(cls.dirname, cls.backend) + + # open the database + cls.db = cls.instance.open('admin') + + # add a user without edit access for status. + cls.db.user.create(username="fred", roles='User', + password=password.Password('sekrit'), address='fred@example.com') + + # set the url the test instance will run at. + cls.db.config['TRACKER_WEB'] = "http://localhost:9001/" + # set up mailhost so errors get reported to debuging capture file + cls.db.config.MAILHOST = "localhost" + cls.db.config.MAIL_HOST = "localhost" + cls.db.config.MAIL_DEBUG = "../_test_tracker_mail.log" + + # added to enable csrf forgeries/CORS to be tested + cls.db.config.WEB_CSRF_ENFORCE_HEADER_ORIGIN = "required" + cls.db.config.WEB_ALLOWED_API_ORIGINS = "https://client.com" + cls.db.config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] = "required" + + # set login failure api limits + cls.db.config.WEB_API_FAILED_LOGIN_LIMIT = 4 + cls.db.config.WEB_API_FAILED_LOGIN_INTERVAL_IN_SEC = 12 + + # enable static precompressed files + cls.db.config.WEB_USE_PRECOMPRESSED_FILES = 1 + + cls.db.config.save() + + cls.db.commit() + cls.db.close() + + # re-open the database to get the updated INDEXER + cls.db = cls.instance.open('admin') + + result = cls.db.issue.create(title="foo bar RESULT") + + # add a message to allow retrieval + result = cls.db.msg.create(author = "1", + content = "a message foo bar RESULT", + date=rdate.Date(), + messageid="test-msg-id") + + cls.db.commit() + cls.db.close() + + # Force locale config to find locales in checkout not in + # installed directories + cls.backup_domain = i18n.DOMAIN + cls.backup_locale_dirs = i18n.LOCALE_DIRS + i18n.LOCALE_DIRS = ['locale'] + i18n.DOMAIN = '' + + def test_rest_login_RateLimit(self): + """login rate limit applies to api endpoints. Only failure + logins count though. So log in 10 times in a row + to verify that valid username/passwords aren't limited. + """ + + # verify that valid logins are not counted against the limit. + for i in range(10): + # use basic auth for rest endpoint + + request_headers = {'content-type': "", + 'Origin': "http://localhost:9001",} + f = requests.options(self.url_base() + '/rest/data', + auth=('admin', 'sekrit'), + headers=request_headers + ) + #print(f.status_code) + #print(f.headers) + #print(f.text) + + self.assertEqual(f.status_code, 204) + + # Save time. check headers only for final response. + headers_expected = { + 'Access-Control-Allow-Origin': request_headers['Origin'], + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override', + 'Allow': 'OPTIONS, GET', + 'Access-Control-Allow-Methods': 'OPTIONS, GET', + 'Access-Control-Allow-Credentials': 'true', + } + + for header in headers_expected.keys(): + self.assertEqual(f.headers[header], + headers_expected[header]) + + + # first 3 logins should report 401 then the rest should report + # 429 + headers_expected = { + 'Content-Type': 'text/plain' + } + + for i in range(10): + # use basic auth for rest endpoint + + f = requests.options(self.url_base() + '/rest/data', + auth=('admin', 'ekrit'), + headers = {'content-type': "", + 'Origin': "http://localhost:9001",} + ) + + if (i < 4): # assuming limit is 4. + for header in headers_expected.keys(): + self.assertEqual(f.headers[header], + headers_expected[header]) + self.assertEqual(f.status_code, 401) + else: + self.assertEqual(f.status_code, 429) + + headers_expected = { 'Content-Type': 'text/plain', + 'X-RateLimit-Limit': '4', + 'X-RateLimit-Limit-Period': '12', + 'X-RateLimit-Remaining': '0', + 'Retry-After': '3', + 'Access-Control-Expose-Headers': + ('X-RateLimit-Limit, ' + 'X-RateLimit-Remaining, ' + 'X-RateLimit-Reset, ' + 'X-RateLimit-Limit-Period, ' + 'Retry-After'), + 'Content-Length': '50'} + + for header in headers_expected.keys(): + self.assertEqual(f.headers[header], + headers_expected[header]) + + self.assertAlmostEqual(float(f.headers['X-RateLimit-Reset']), + 10.0, delta=3, + msg="limit reset not within 3 seconds of 10") + + # test lockout this is a valid login but should be rejected + # with 429. + f = requests.options(self.url_base() + '/rest/data', + auth=('admin', 'sekrit'), + headers = {'content-type': "", + 'Origin': "http://localhost:9001",} + ) + self.assertEqual(f.status_code, 429) + + for header in headers_expected.keys(): + self.assertEqual(f.headers[header], + headers_expected[header]) + + + sleep(4) + # slept long enough to get a login slot. Should work with + # 200 return code. + f = requests.get(self.url_base() + '/rest/data', + auth=('admin', 'sekrit'), + headers = {'content-type': "", + 'Origin': "http://localhost:9001",} + ) + self.assertEqual(f.status_code, 200) + print(i, f.status_code) + print(f.headers) + print(f.text) + + headers_expected = { + 'Content-Type': 'application/json', + 'Vary': 'Origin, Accept-Encoding', + 'Access-Control-Expose-Headers': + ( 'X-RateLimit-Limit, ' + 'X-RateLimit-Remaining, ' + 'X-RateLimit-Reset, ' + 'X-RateLimit-Limit-Period, ' + 'Retry-After, ' + 'Sunset, ' + 'Allow'), + 'Access-Control-Allow-Origin': 'http://localhost:9001', + 'Access-Control-Allow-Credentials': 'true', + 'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH', + 'Content-Length': '167', + 'Content-Encoding': 'gzip'} + + for header in headers_expected.keys(): + self.assertEqual(f.headers[header], + headers_expected[header])
