Mercurial > p > roundup > code
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]) |
