CVE-2022-3439 (Python, published 2022-10-14T12:15Z)
Description: value: Allocation of Resources Without Limits or Throttling in GitHub repository ikus060/rdiffweb prior to 2.5.0.
Severity: nan (CVSS v3 base score: 9.8)
Weakness: CWE-770 \u2014 Allocation of Resources Without Limits or Throttling
The product allocates a reusable resource or group of resources on behalf of an actor without imposing any restrictions on the size or number of resources that can be allocated, in violation of the intended security policy for that actor.
Repository: https://github.com/ikus060/rdiffweb
Files changed:
README.md
doc/configuration.md
rdiffweb/controller/api.py
rdiffweb/controller/form.py
rdiffweb/controller/page_admin_session.py
rdiffweb/controller/page_login.py
rdiffweb/controller/page_mfa.py
rdiffweb/controller/page_pref_general.py
rdiffweb/controller/page_pref_session.py
rdiffweb/controller/tests/test_api.py
rdiffweb/controller/tests/test_page_login.py
rdiffweb/controller/tests/test_page_prefs_general.py
rdiffweb/core/config.py
rdiffweb/rdw_app.py
rdiffweb/tools/ratelimit.py
Commit message: Improve ratelimit implementation


Vulnerable code:
| rate-limit | maximum number of requests per minute that can be made by an IP address for an unauthenticated connection. When this limit is reached, an HTTP 429 message is returned to the user. This security measure is used to limit brute force attacks on the login page and the RESTful API. | 10 |
    return any(cherrypy.engine.publish('login', username, password))
@cherrypy.tools.sessions(on=False)
@cherrypy.tools.ratelimit()
SUBMIT_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'}

        return cherrypy.request.method in SUBMIT_METHODS
    @property
    def app(self):
        return cherrypy.request.app

    @cherrypy.tools.ratelimit()
    @cherrypy.tools.ratelimit()
# Maximum number of password change attempt before logout
CHANGE_PASSWORD_MAX_ATTEMPT = 5
CHANGE_PASSWORD_ATTEMPTS = 'change_password_attempts'

            cherrypy.session[CHANGE_PASSWORD_ATTEMPTS] = cherrypy.session.get(CHANGE_PASSWORD_ATTEMPTS, 0) + 1
            attempts = cherrypy.session[CHANGE_PASSWORD_ATTEMPTS]
            if attempts >= CHANGE_PASSWORD_MAX_ATTEMPT:
                cherrypy.session.clear()
                cherrypy.session.regenerate()
                flash(
                    _("You were logged out because you entered the wrong password too many times."),
                    level='warning',
                )
                raise cherrypy.HTTPRedirect('/login/')

        # Clear number of attempts
        if CHANGE_PASSWORD_ATTEMPTS in cherrypy.session:
            del cherrypy.session[CHANGE_PASSWORD_ATTEMPTS]

    @property
    def app(self):
        return cherrypy.request.app

        for i in range(0, 6):
            self.getPage('/api/')

from parameterized import parameterized

    default_config = {
        'rate-limit': 5,
    }
        # When requesting multple time the login page
        for i in range(0, 6):
            self.getPage('/login/')
class LoginPageRateLimitWithSessionDirTest(rdiffweb.test.WebCase):
    default_config = {
        'rate-limit-dir': '/tmp',
        'rate-limit': 5,
    }
    def test_login_ratelimit(self):
        # When requesting multple time the login page
        for i in range(0, 6):
            self.getPage('/login/')
        # Then a 429 error (too many request) is return
class LoginPageRateLimitTestWithXForwardedFor(rdiffweb.test.WebCase):
    default_config = {
        'rate-limit': 5,
    }

    def test_login_ratelimit(self):
        # When requesting multple time the login page
        for i in range(0, 6):
            self.getPage('/login/', headers=[('X-Forwarded-For', '127.0.0.%s' % i)])
        # Then a 429 error (too many request) is return
class LoginPageRateLimitTestWithXRealIP(rdiffweb.test.WebCase):

    default_config = {
        'rate-limit': 5,
    }

    def test_login_ratelimit(self):
        # Given an unauthenticate
        # When requesting multple time the login page
        for i in range(0, 6):
            self.getPage('/login/', headers=[('X-Real-IP', '127.0.0.%s' % i)])
        # Then a 200 is return.
        self.assertStatus(200)


    def test_change_password_too_many_attemps(self):
        # When udating user's password with wrong current password 5 times
        for _i in range(1, 5):
            self._set_password('wrong', "pr3j5Dwi", "pr3j5Dwi")
            self.assertStatus(200)
            self.assertInBody("Wrong current password.")
        # Then user session is cleared and user is redirect to login page
        self._set_password('wrong', "pr3j5Dwi", "pr3j5Dwi")
        self.assertStatus(303)
        self.assertHeaderItemValue('Location', self.baseurl + '/login/')
        # Then a warning message is displayed on login page
        self.getPage('/login/')
        self.assertStatus(200)
        self.assertInBody('You were logged out because you entered the wrong password too many times.')

        default=30,
        help='maximum number of requests per minute that can be made by an IP address for an unauthenticated connection. When this limit is reached, an HTTP 429 message is returned to the user. This security measure is used to limit brute force attacks on the login page and the RESTful API.',
                'tools.ratelimit.delay': 60,
                'tools.ratelimit.anonymous_limit': cfg.rate_limit,
# rdiffweb, A web interface to rdiff-backup repositories
# Copyright (C) 2012-2021 rdiffweb contributors
    def get_and_increment(self, token, delay):
            tracker = tracker._replace(hits=tracker.hits + 1)
        except (IOError, EOFError):
def check_ratelimit(delay=60, anonymous_limit=0, registered_limit=0, rate_exceed_status=429, debug=False, **conf):
    Verify the ratelimit. By default return a 429 HTTP error code (Too Many Request).
    Usage:
    @cherrypy.tools.ratelimit(on=True, anonymous_limit=5, registered_limit=50, storage_class=FileRateLimit, storage_path='/tmp')
    def index(self):
        pass
    """
    datastore = getattr(cherrypy, '_ratelimit_datastore', None)
        cherrypy._ratelimit_datastore = datastore
    token = cherrypy.request.login or cherrypy.request.remote.ip

    # Get the real limit depending of user login.
    limit = registered_limit if cherrypy.request.login else anonymous_limit
    if limit is None or limit <= 0:
        return
    hits = datastore.get_and_increment(token, delay)
            'check and increase rate limit for token %s, limit %s, hits %s' % (token, limit, hits), 'TOOLS.RATELIMIT'
        raise cherrypy.HTTPError(rate_exceed_status)

Fixed code:
* Enforce better rate limit on login, mfa, password change and API [CVE-2022-3439](https://nvd.nist.gov/vuln/detail/CVE-2022-3439) [CVE-2022-3456](https://nvd.nist.gov/vuln/detail/CVE-2022-3456)
| rate-limit | maximum number of requests per hour that can be made on sensitive endpoints. When this limit is reached, an HTTP 429 message is returned to the user or the user is logged out. This security measure is used to limit brute force attacks on the login page and the RESTful API. | 20 |
            cherrypy.tools.ratelimit.hit()
    valid = any(cherrypy.engine.publish('login', username, password))
    if not valid:
        # When invalid, we need to increase the rate limit.
        cherrypy.tools.ratelimit.hit()
    return valid
@cherrypy.tools.ratelimit(scope='rdiffweb-api', hit=0, priority=69)
@cherrypy.tools.sessions(on=False)
        return cherrypy.request.method == 'POST'
    @cherrypy.tools.ratelimit(methods=['POST'])
    @cherrypy.tools.ratelimit(methods=['POST'])
    @cherrypy.tools.ratelimit(methods=['POST'], logout=True)
        # Given invalid credentials sent to API
        headers = [("Authorization", "Basic " + b64encode(b"admin:invalid").decode('ascii'))]
        for i in range(1, 5):
            self.getPage('/api/', headers=headers)
            self.assertStatus(401)
        # Then the 6th request is refused
        self.getPage('/api/', headers=headers)
        self.assertStatus(429)
        # Next request is also refused event if credentials are valid.
        self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b"admin:admin123").decode('ascii'))])
import os
from parameterized import parameterized, parameterized_class
@parameterized_class(
    [
        {"default_config": {'rate-limit': 5}},
        {"default_config": {'rate-limit': 5, 'rate-limit-dir': '/tmp'}},
    ]
)
    def setUp(self):
        if os.path.isfile('/tmp/ratelimit-127.0.0.1'):
            os.unlink('/tmp/ratelimit-127.0.0.1')
        if os.path.isfile('/tmp/ratelimit-127.0.0.1.-login'):
            os.unlink('/tmp/ratelimit-127.0.0.1.-login')
        return super().setUp()
        # When requesting multiple time the login page
        for i in range(1, 5):
            self.getPage('/login/', method='POST', body={'login': 'invalid', 'password': 'invalid'})
            self.assertStatus(200)
        self.getPage('/login/', method='POST', body={'login': 'invalid', 'password': 'invalid'})
class LoginPageRateLimitTest2(rdiffweb.test.WebCase):
    default_config = {'rate-limit': 5}
    def test_login_ratelimit_forwarded_for(self):
        # When requesting multiple time the login page with different `X-Forwarded-For`
        for i in range(1, 5):
            self.getPage(
                '/login/',
                headers=[('X-Forwarded-For', '127.0.0.%s' % i)],
                method='POST',
                body={'login': 'invalid', 'password': 'invalid'},
            )
            self.assertStatus(200)
        # Then original IP get blocked
        self.getPage(
            '/login/',
            headers=[('X-Forwarded-For', '127.0.0.%s' % i)],
            method='POST',
            body={'login': 'invalid', 'password': 'invalid'},
        )
class LoginPageRateLimitTest3(rdiffweb.test.WebCase):
    default_config = {'rate-limit': 5}
    def test_login_ratelimit_real_ip(self):
        # When requesting multiple time the login page with different `X-Real-IP`
        for i in range(1, 5):
            self.getPage(
                '/login/',
                headers=[('X-Real-IP', '127.0.0.128')],
                method='POST',
                body={'login': 'invalid', 'password': 'invalid'},
            )
            self.assertStatus(200)
        # Then the X-Real-IP get blocked
        self.getPage(
            '/login/',
            headers=[('X-Real-IP', '127.0.0.128')],
            method='POST',
            body={'login': 'invalid', 'password': 'invalid'},
        )


class PagePrefGeneralRateLimitTest(rdiffweb.test.WebCase):
    login = True

    default_config = {'rate-limit': 5}

    def test_change_password_too_many_attemps(self):
        # When udating user's password with wrong current password 5 times
        for _i in range(1, 5):
            self.getPage(
                '/prefs/general',
                method='POST',
                body={'action': 'set_password', 'current': 'wrong', 'new': 'pr3j5Dwi', 'confirm': 'pr3j5Dwi'},
            )
            self.assertStatus(200)
            self.assertInBody("Wrong current password.")
        # Then user session is cleared and user is redirect to login page
        self.getPage(
            '/prefs/general',
            method='POST',
            body={'action': 'set_password', 'current': 'wrong', 'new': 'pr3j5Dwi', 'confirm': 'pr3j5Dwi'},
        )
        self.assertStatus(303)
        self.assertHeaderItemValue('Location', self.baseurl + '/')
        # Then a warning message is displayed on login page
        self.getPage('/login/')
        self.assertStatus(200)
        default=20,
        help='maximum number of requests per hour that can be made on sensitive endpoints. When this limit is reached, an HTTP 429 message is returned to the user or the user is logged out. This security measure is used to limit brute force attacks on the login page and the RESTful API.',
                'tools.ratelimit.delay': 3600,
                'tools.ratelimit.limit': cfg.rate_limit,
# udb, A web interface to manage IT network
# Copyright (C) 2022 IKUS Software inc.
    def get_and_increment(self, token, delay, hit=1):
            tracker = tracker._replace(hits=tracker.hits + hit)
        except Exception:
def check_ratelimit(
    delay=3600, limit=25, return_status=429, logout=False, scope=None, methods=None, debug=False, hit=1, **conf
):
    Verify the ratelimit. By default return a 429 HTTP error code (Too Many Request). After 25 request within the same hour.

    Arguments:
        delay:         Time window for analysis in seconds. Default per hour (3600 seconds)
        limit:         Number of request allowed for an entry point. Default 25
        return_status: HTTP Error code to return.
        logout:        True to logout user when limit is reached
        scope:         if specify, define the scope of rate limit. Default to path_info.
        methods:       if specify, only the methods in the list will be rate limited.
    """
    assert delay > 0, 'invalid delay'
    # Check if limit is enabled
    if limit <= 0:
        return
    # Check if this 'method' should be rate limited
    request = cherrypy.request
    if methods is not None and request.method not in methods:
        if debug:
            cherrypy.log(
                'skip rate limit for HTTP method %s' % (request.method,),
                'TOOLS.RATELIMIT',
            )
        return
    datastore = getattr(cherrypy.request.app, '_ratelimit_datastore', None)
        cherrypy.request.app._ratelimit_datastore = datastore
    token = (request.login or request.remote.ip) + '.' + (scope or request.path_info)
    hits = datastore.get_and_increment(token, delay, hit)
            'check and increase rate limit for scope %s, limit %s, hits %s' % (token, limit, hits), 'TOOLS.RATELIMIT'
        if logout:
            if hasattr(cherrypy, 'session'):
                cherrypy.session.clear()
            raise cherrypy.HTTPRedirect("/")

        raise cherrypy.HTTPError(return_status)


def hit(hit=1):
    """
    May be called directly by handlers to add a hit for the given request.
    """
    conf = cherrypy.tools.ratelimit._merged_args()
    conf['hit'] = hit
    cherrypy.tools.ratelimit.callable(**conf)


cherrypy.tools.ratelimit.hit = hit