comparison 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
comparison
equal deleted inserted replaced
7555:451232f83244 7556:273c8c2b5042
5 from roundup import password 5 from roundup import password
6 from roundup.anypy.strings import b2s 6 from roundup.anypy.strings import b2s
7 from roundup.cgi.wsgi_handler import RequestDispatcher 7 from roundup.cgi.wsgi_handler import RequestDispatcher
8 from .wsgi_liveserver import LiveServerTestCase 8 from .wsgi_liveserver import LiveServerTestCase
9 from . import db_test_base 9 from . import db_test_base
10 from time import sleep
10 11
11 from wsgiref.validate import validator 12 from wsgiref.validate import validator
12 13
13 try: 14 try:
14 import requests 15 import requests
614 print(f.status_code) 615 print(f.status_code)
615 print(f.headers) 616 print(f.headers)
616 617
617 self.assertEqual(f.status_code, 404) 618 self.assertEqual(f.status_code, 404)
618 619
619 def DISABLEtest_rest_login_rate_limit(self):
620 """login rate limit applies to api endpoints. Only failure
621 logins count though. So log in 10 times in a row
622 to verify that valid username/passwords aren't limited.
623
624 FIXME: client.py does not implement this. Also need a live
625 server instance that has
626
627 cls.db.config.WEB_LOGIN_ATTEMPTS_MIN = 4
628
629 not 0.
630 """
631
632 for i in range(10):
633 # use basic auth for rest endpoint
634
635 f = requests.options(self.url_base() + '/rest/data',
636 auth=('admin', 'sekrit'),
637 headers = {'content-type': "",
638 'Origin': "http://localhost:9001",}
639 )
640 print(f.status_code)
641 print(f.headers)
642
643 self.assertEqual(f.status_code, 204)
644 expected = { 'Access-Control-Allow-Origin': '*',
645 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
646 'Allow': 'OPTIONS, GET',
647 'Access-Control-Allow-Methods': 'HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH',
648 'Access-Control-Allow-Credentials': 'true',
649 }
650
651 for i in range(10):
652 # use basic auth for rest endpoint
653
654 f = requests.options(self.url_base() + '/rest/data',
655 auth=('admin', 'ekrit'),
656 headers = {'content-type': "",
657 'Origin': "http://localhost:9001",}
658 )
659 print(i, f.status_code)
660 print(f.headers)
661 print(f.text)
662
663 if (i < 3): # assuming limit is 4.
664 self.assertEqual(f.status_code, 401)
665 else:
666 self.assertEqual(f.status_code, 429)
667
668 def test_ims(self): 620 def test_ims(self):
669 ''' retreive the user_utils.js file with old and new 621 ''' retreive the user_utils.js file with old and new
670 if-modified-since timestamps. 622 if-modified-since timestamps.
671 ''' 623 '''
672 from datetime import datetime 624 from datetime import datetime
1359 self.assertIn("postgresql_fts", str(self.db.indexer)) 1311 self.assertIn("postgresql_fts", str(self.db.indexer))
1360 1312
1361 # use a ts: search as well so it only works on postgres_fts indexer 1313 # use a ts: search as well so it only works on postgres_fts indexer
1362 f = requests.get(self.url_base() + "?@search_text=ts:RESULT") 1314 f = requests.get(self.url_base() + "?@search_text=ts:RESULT")
1363 self.assertIn("foo bar RESULT", f.text) 1315 self.assertIn("foo bar RESULT", f.text)
1316
1317 class TestApiRateLogin(WsgiSetup):
1318 """Class to run test in BaseTestCases with the cache_tracker
1319 feature flag enabled when starting the wsgi server
1320 """
1321
1322 backend = 'sqlite'
1323
1324 @classmethod
1325 def setup_class(cls):
1326 '''All tests in this class use the same roundup instance.
1327 This instance persists across all tests.
1328 Create the tracker dir here so that it is ready for the
1329 create_app() method to be called.
1330
1331 cribbed from WsgiSetup::setup_class
1332 '''
1333
1334 # tests in this class.
1335 # set up and open a tracker
1336 cls.instance = db_test_base.setupTracker(cls.dirname, cls.backend)
1337
1338 # open the database
1339 cls.db = cls.instance.open('admin')
1340
1341 # add a user without edit access for status.
1342 cls.db.user.create(username="fred", roles='User',
1343 password=password.Password('sekrit'), address='fred@example.com')
1344
1345 # set the url the test instance will run at.
1346 cls.db.config['TRACKER_WEB'] = "http://localhost:9001/"
1347 # set up mailhost so errors get reported to debuging capture file
1348 cls.db.config.MAILHOST = "localhost"
1349 cls.db.config.MAIL_HOST = "localhost"
1350 cls.db.config.MAIL_DEBUG = "../_test_tracker_mail.log"
1351
1352 # added to enable csrf forgeries/CORS to be tested
1353 cls.db.config.WEB_CSRF_ENFORCE_HEADER_ORIGIN = "required"
1354 cls.db.config.WEB_ALLOWED_API_ORIGINS = "https://client.com"
1355 cls.db.config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] = "required"
1356
1357 # set login failure api limits
1358 cls.db.config.WEB_API_FAILED_LOGIN_LIMIT = 4
1359 cls.db.config.WEB_API_FAILED_LOGIN_INTERVAL_IN_SEC = 12
1360
1361 # enable static precompressed files
1362 cls.db.config.WEB_USE_PRECOMPRESSED_FILES = 1
1363
1364 cls.db.config.save()
1365
1366 cls.db.commit()
1367 cls.db.close()
1368
1369 # re-open the database to get the updated INDEXER
1370 cls.db = cls.instance.open('admin')
1371
1372 result = cls.db.issue.create(title="foo bar RESULT")
1373
1374 # add a message to allow retrieval
1375 result = cls.db.msg.create(author = "1",
1376 content = "a message foo bar RESULT",
1377 date=rdate.Date(),
1378 messageid="test-msg-id")
1379
1380 cls.db.commit()
1381 cls.db.close()
1382
1383 # Force locale config to find locales in checkout not in
1384 # installed directories
1385 cls.backup_domain = i18n.DOMAIN
1386 cls.backup_locale_dirs = i18n.LOCALE_DIRS
1387 i18n.LOCALE_DIRS = ['locale']
1388 i18n.DOMAIN = ''
1389
1390 def test_rest_login_RateLimit(self):
1391 """login rate limit applies to api endpoints. Only failure
1392 logins count though. So log in 10 times in a row
1393 to verify that valid username/passwords aren't limited.
1394 """
1395
1396 # verify that valid logins are not counted against the limit.
1397 for i in range(10):
1398 # use basic auth for rest endpoint
1399
1400 request_headers = {'content-type': "",
1401 'Origin': "http://localhost:9001",}
1402 f = requests.options(self.url_base() + '/rest/data',
1403 auth=('admin', 'sekrit'),
1404 headers=request_headers
1405 )
1406 #print(f.status_code)
1407 #print(f.headers)
1408 #print(f.text)
1409
1410 self.assertEqual(f.status_code, 204)
1411
1412 # Save time. check headers only for final response.
1413 headers_expected = {
1414 'Access-Control-Allow-Origin': request_headers['Origin'],
1415 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, X-HTTP-Method-Override',
1416 'Allow': 'OPTIONS, GET',
1417 'Access-Control-Allow-Methods': 'OPTIONS, GET',
1418 'Access-Control-Allow-Credentials': 'true',
1419 }
1420
1421 for header in headers_expected.keys():
1422 self.assertEqual(f.headers[header],
1423 headers_expected[header])
1424
1425
1426 # first 3 logins should report 401 then the rest should report
1427 # 429
1428 headers_expected = {
1429 'Content-Type': 'text/plain'
1430 }
1431
1432 for i in range(10):
1433 # use basic auth for rest endpoint
1434
1435 f = requests.options(self.url_base() + '/rest/data',
1436 auth=('admin', 'ekrit'),
1437 headers = {'content-type': "",
1438 'Origin': "http://localhost:9001",}
1439 )
1440
1441 if (i < 4): # assuming limit is 4.
1442 for header in headers_expected.keys():
1443 self.assertEqual(f.headers[header],
1444 headers_expected[header])
1445 self.assertEqual(f.status_code, 401)
1446 else:
1447 self.assertEqual(f.status_code, 429)
1448
1449 headers_expected = { 'Content-Type': 'text/plain',
1450 'X-RateLimit-Limit': '4',
1451 'X-RateLimit-Limit-Period': '12',
1452 'X-RateLimit-Remaining': '0',
1453 'Retry-After': '3',
1454 'Access-Control-Expose-Headers':
1455 ('X-RateLimit-Limit, '
1456 'X-RateLimit-Remaining, '
1457 'X-RateLimit-Reset, '
1458 'X-RateLimit-Limit-Period, '
1459 'Retry-After'),
1460 'Content-Length': '50'}
1461
1462 for header in headers_expected.keys():
1463 self.assertEqual(f.headers[header],
1464 headers_expected[header])
1465
1466 self.assertAlmostEqual(float(f.headers['X-RateLimit-Reset']),
1467 10.0, delta=3,
1468 msg="limit reset not within 3 seconds of 10")
1469
1470 # test lockout this is a valid login but should be rejected
1471 # with 429.
1472 f = requests.options(self.url_base() + '/rest/data',
1473 auth=('admin', 'sekrit'),
1474 headers = {'content-type': "",
1475 'Origin': "http://localhost:9001",}
1476 )
1477 self.assertEqual(f.status_code, 429)
1478
1479 for header in headers_expected.keys():
1480 self.assertEqual(f.headers[header],
1481 headers_expected[header])
1482
1483
1484 sleep(4)
1485 # slept long enough to get a login slot. Should work with
1486 # 200 return code.
1487 f = requests.get(self.url_base() + '/rest/data',
1488 auth=('admin', 'sekrit'),
1489 headers = {'content-type': "",
1490 'Origin': "http://localhost:9001",}
1491 )
1492 self.assertEqual(f.status_code, 200)
1493 print(i, f.status_code)
1494 print(f.headers)
1495 print(f.text)
1496
1497 headers_expected = {
1498 'Content-Type': 'application/json',
1499 'Vary': 'Origin, Accept-Encoding',
1500 'Access-Control-Expose-Headers':
1501 ( 'X-RateLimit-Limit, '
1502 'X-RateLimit-Remaining, '
1503 'X-RateLimit-Reset, '
1504 'X-RateLimit-Limit-Period, '
1505 'Retry-After, '
1506 'Sunset, '
1507 'Allow'),
1508 'Access-Control-Allow-Origin': 'http://localhost:9001',
1509 'Access-Control-Allow-Credentials': 'true',
1510 'Allow': 'OPTIONS, GET, POST, PUT, DELETE, PATCH',
1511 'Content-Length': '167',
1512 'Content-Encoding': 'gzip'}
1513
1514 for header in headers_expected.keys():
1515 self.assertEqual(f.headers[header],
1516 headers_expected[header])

Roundup Issue Tracker: http://roundup-tracker.org/