CVE-2022-2822: Improper Restriction of Excessive Authentication Attempts (CWE-307)
value: An attacker can freely brute force username and password and can takeover any account. An attacker could easily guess user passwords and gain access to user and administrative accounts.
CWE description: The product does not implement sufficient measures to prevent multiple failed authentication attempts within a short time frame, making it more susceptible to brute force attacks.
CVSS v3 base score: 7.5
Published: 2022-08-15T11:21Z
Repository: https://github.com/octoprint/octoprint
Language: Python
Files changed: setup.py, src/octoprint/server/__init__.py, src/octoprint/server/api/__init__.py, src/octoprint/static/css/login.css, src/octoprint/static/js/login/login.js, src/octoprint/static/less/login.less, src/octoprint/templates/login.jinja2
Commit date: 2022-08-15 11:21:59 +0200
Commit message: 🔒️ Rate limit failed login attempts

Should reduce likelihood of brute force attacks
on unsecure networks if users decide to deploy/make
OctoPrint accessible there against all advice to
the contrary.

Diff: diff --git a/setup.py b/setup.py
index 2c0988b31..4aafc687a 100644
--- a/setup.py
+++ b/setup.py
@@ -41,10 +41,11 @@ core_deps = [
     "feedparser>=6.0.8,<7",
     "filetype>=1.0.7,<2",
     "Flask-Assets>=2.0,<3",
     "Flask-Babel>=2.0,<3",
     "Flask-Login>=0.6,<0.7",  # breaking changes can happen on minor version increases
+    "Flask-Limiter>=2.6,<3",
     "flask>=2.2,<2.3",  # breaking changes can happen on minor version increases (with deprecation warnings)
     "frozendict>=2.0,<3",
     "future>=0.18.2,<1",  # not really needed anymore, but leaving in for py2/3 compat plugins
     "markdown>=3.2.2,<4",
     "netaddr>=0.8,<0.9",  # changelog hints at breaking changes on minor version increases
diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py
index eea4b319b..53b3d6b98 100644
--- a/src/octoprint/server/__init__.py
+++ b/src/octoprint/server/__init__.py
@@ -68,10 +68,11 @@ NOT_MODIFIED = ("Not Modified", 304, {"Content-Type": "text/plain"})
 
 app = Flask("octoprint")
 
 assets = None
 babel = None
+limiter = None
 debug = False
 safe_mode = False
 
 printer = None
 printerProfileManager = None
@@ -1336,10 +1337,12 @@ class Server:
 
         timer = octoprint.util.RepeatedTimer(interval, log_heartbeat)
         timer.start()
 
     def _setup_app(self, app):
+        global limiter
+
         from octoprint.server.util.flask import (
             OctoPrintFlaskRequest,
             OctoPrintFlaskResponse,
             OctoPrintJsonEncoder,
             OctoPrintSessionInterface,
@@ -1431,10 +1434,17 @@ class Server:
 
         from octoprint.util.jinja import MarkdownFilter
 
         MarkdownFilter(app)
 
+        from flask_limiter import Limiter
+        from flask_limiter.util import get_remote_address
+
+        app.config["RATELIMIT_STRATEGY"] = "fixed-window-elastic-expiry"
+
+        limiter = Limiter(app, key_func=get_remote_address)
+
     def _setup_i18n(self, app):
         global babel
         global LOCALES
         global LANGUAGES
 
diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py
index 83b625e74..08d9ccdd0 100644
--- a/src/octoprint/server/api/__init__.py
+++ b/src/octoprint/server/api/__init__.py
@@ -279,10 +279,15 @@ def serverStatus():
 
 # ~~ Login/user handling
 
 
 @api.route("/login", methods=["POST"])
+@octoprint.server.limiter.limit(
+    "3/minute;5/10 minutes;10/hour",
+    deduct_when=lambda response: response.status_code == 403,
+    error_message="You have made too many failed login attempts. Please try again later.",
+)
 def login():
     data = request.get_json()
     if not data:
         data = request.values
 
diff --git a/src/octoprint/static/css/login.css b/src/octoprint/static/css/login.css
index ad8d23828..ff21ac632 100644
--- a/src/octoprint/static/css/login.css
+++ b/src/octoprint/static/css/login.css
@@ -1 +1 @@
-body{padding-top:40px;padding-bottom:40px;background-color:#f5f5f5}.form-signin{max-width:300px;padding:19px 29px 29px;margin:0 auto 20px;background-color:#fff;border:1px solid #e5e5e5;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.form-signin .form-signin-heading,.form-signin .checkbox{margin-bottom:10px}.form-signin input[type="text"],.form-signin input[type="password"]{font-size:16px;height:auto;margin-bottom:15px;padding:7px 9px}#login-error,#login-offline{display:none}#login-error.in,#login-offline.in{display:block}#login-overlay{position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;display:none}#login-overlay.in{display:block}#login-overlay .background{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#000000;filter:alpha(opacity=50);-moz-opacity:.5;-khtml-opacity:.5;opacity:.5}#login-overlay .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#login-overlay .wrapper .outer{display:table;width:100%;height:100%}#login-overlay .wrapper .outer .inner{display:table-cell;vertical-align:middle}#login-overlay .wrapper .outer .inner .content{text-align:center;color:white}#noscript{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#ffffff;z-index:12310}#noscript .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#noscript .wrapper .outer{display:table;width:100%;height:100%}#noscript .wrapper .outer .inner{display:table-cell;vertical-align:middle}#noscript .wrapper .outer .inner .content{text-align:center}
\ No newline at end of file
+body{padding-top:40px;padding-bottom:40px;background-color:#f5f5f5}.form-signin{max-width:300px;padding:19px 29px 29px;margin:0 auto 20px;background-color:#fff;border:1px solid #e5e5e5;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.form-signin .form-signin-heading,.form-signin .checkbox{margin-bottom:10px}.form-signin input[type="text"],.form-signin input[type="password"]{font-size:16px;height:auto;margin-bottom:15px;padding:7px 9px}#login-error-credentials,#login-error-rate,#login-offline{display:none}#login-error-credentials.in,#login-error-rate.in,#login-offline.in{display:block}#login-overlay{position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;display:none}#login-overlay.in{display:block}#login-overlay .background{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#000000;filter:alpha(opacity=50);-moz-opacity:.5;-khtml-opacity:.5;opacity:.5}#login-overlay .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#login-overlay .wrapper .outer{display:table;width:100%;height:100%}#login-overlay .wrapper .outer .inner{display:table-cell;vertical-align:middle}#login-overlay .wrapper .outer .inner .content{text-align:center;color:white}#noscript{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#ffffff;z-index:12310}#noscript .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#noscript .wrapper .outer{display:table;width:100%;height:100%}#noscript .wrapper .outer .inner{display:table-cell;vertical-align:middle}#noscript .wrapper .outer .inner .content{text-align:center}
\ No newline at end of file
diff --git a/src/octoprint/static/js/login/login.js b/src/octoprint/static/js/login/login.js
index c9107d2cf..325634497 100644
--- a/src/octoprint/static/js/login/login.js
+++ b/src/octoprint/static/js/login/login.js
@@ -9,11 +9,12 @@ $(function () {
     OctoPrint.loginui = {
         startedUp: false
     };
 
     var overlayElement = $("#login-overlay");
-    var errorElement = $("#login-error");
+    var errorCredentialsElement = $("#login-error-credentials");
+    var errorRateElement = $("#login-error-rate");
     var offlineElement = $("#login-offline");
     var buttonElement = $("#login-button");
     var reconnectElement = $("#login-reconnect");
 
     var ignoreDisconnect = false;
@@ -26,30 +27,35 @@ $(function () {
         var username = usernameElement.val();
         var password = passwordElement.val();
         var remember = rememberElement.prop("checked");
 
         overlayElement.addClass("in");
-        errorElement.removeClass("in");
+        errorCredentialsElement.removeClass("in");
+        errorRateElement.removeClass("in");
 
         OctoPrint.browser
             .login(username, password, remember)
             .done(() => {
                 ignoreDisconnect = true;
                 window.location.href = REDIRECT_URL;
             })
-            .fail(() => {
+            .fail((xhr) => {
                 usernameElement.val(USER_ID);
                 passwordElement.val("");
 
                 if (USER_ID) {
                     passwordElement.focus();
                 } else {
                     usernameElement.focus();
                 }
 
                 overlayElement.removeClass("in");
-                errorElement.addClass("in");
+                if (xhr.status === 429) {
+                    errorRateElement.addClass("in");
+                } else {
+                    errorCredentialsElement.addClass("in");
+                }
             });
 
         return false;
     });
 
diff --git a/src/octoprint/static/less/login.less b/src/octoprint/static/less/login.less
index a54d692fc..8d4f0b8ae 100644
--- a/src/octoprint/static/less/login.less
+++ b/src/octoprint/static/less/login.less
@@ -29,11 +29,12 @@ body {
     margin-bottom: 15px;
     padding: 7px 9px;
   }
 }
 
-#login-error,
+#login-error-credentials,
+#login-error-rate,
 #login-offline {
   display: none;
 
   &.in {
     display: block;
diff --git a/src/octoprint/templates/login.jinja2 b/src/octoprint/templates/login.jinja2
index 252e33dbd..816235634 100644
--- a/src/octoprint/templates/login.jinja2
+++ b/src/octoprint/templates/login.jinja2
@@ -63,11 +63,12 @@
 
     <div id="login" class="container">
         <form class="form-signin">
             <h2 class="form-signin-heading" data-test-id="login-title">{{ _('Please log in') }}</h2>
 
-            <div id="login-error" class="alert alert-error" data-test-id="login-error">{{ _('Incorrect username or password. Hint: Both are case sensitive!') }}</div>
+            <div id="login-error-credentials" class="alert alert-error" data-test-id="login-error">{{ _('Incorrect username or password. Hint: Both are case sensitive!') }}</div>
+            <div id="login-error-rate" class="alert alert-error" data-test-id="login-error-rate">{{ _('You have made too many failed login attempts. Please try again later.') }}</div>
             <div id="login-offline" class="alert alert-error">{{ _('Server is currently offline.') }} <a id="login-reconnect" href="javascript:void(0)">{{ _('Reconnect...') }}</a></div>
 
             {% if user_id %}<p>
                 {{ _('The following account is required:') }} {{ user_id|e }}
             </p>{% elif logged_in %}<p>

Vulnerable code: body{padding-top:40px;padding-bottom:40px;background-color:#f5f5f5}.form-signin{max-width:300px;padding:19px 29px 29px;margin:0 auto 20px;background-color:#fff;border:1px solid #e5e5e5;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.form-signin .form-signin-heading,.form-signin .checkbox{margin-bottom:10px}.form-signin input[type="text"],.form-signin input[type="password"]{font-size:16px;height:auto;margin-bottom:15px;padding:7px 9px}#login-error,#login-offline{display:none}#login-error.in,#login-offline.in{display:block}#login-overlay{position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;display:none}#login-overlay.in{display:block}#login-overlay .background{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#000000;filter:alpha(opacity=50);-moz-opacity:.5;-khtml-opacity:.5;opacity:.5}#login-overlay .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#login-overlay .wrapper .outer{display:table;width:100%;height:100%}#login-overlay .wrapper .outer .inner{display:table-cell;vertical-align:middle}#login-overlay .wrapper .outer .inner .content{text-align:center;color:white}#noscript{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#ffffff;z-index:12310}#noscript .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#noscript .wrapper .outer{display:table;width:100%;height:100%}#noscript .wrapper .outer .inner{display:table-cell;vertical-align:middle}#noscript .wrapper .outer .inner .content{text-align:center}
    var errorElement = $("#login-error");
        errorElement.removeClass("in");
            .fail(() => {
                errorElement.addClass("in");
#login-error,
            <div id="login-error" class="alert alert-error" data-test-id="login-error">{{ _('Incorrect username or password. Hint: Both are case sensitive!') }}</div>
Fixed code:     "Flask-Limiter>=2.6,<3",
limiter = None
        global limiter

        from flask_limiter import Limiter
        from flask_limiter.util import get_remote_address

        app.config["RATELIMIT_STRATEGY"] = "fixed-window-elastic-expiry"

        limiter = Limiter(app, key_func=get_remote_address)

@octoprint.server.limiter.limit(
    "3/minute;5/10 minutes;10/hour",
    deduct_when=lambda response: response.status_code == 403,
    error_message="You have made too many failed login attempts. Please try again later.",
)
body{padding-top:40px;padding-bottom:40px;background-color:#f5f5f5}.form-signin{max-width:300px;padding:19px 29px 29px;margin:0 auto 20px;background-color:#fff;border:1px solid #e5e5e5;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);-moz-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.form-signin .form-signin-heading,.form-signin .checkbox{margin-bottom:10px}.form-signin input[type="text"],.form-signin input[type="password"]{font-size:16px;height:auto;margin-bottom:15px;padding:7px 9px}#login-error-credentials,#login-error-rate,#login-offline{display:none}#login-error-credentials.in,#login-error-rate.in,#login-offline.in{display:block}#login-overlay{position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;display:none}#login-overlay.in{display:block}#login-overlay .background{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#000000;filter:alpha(opacity=50);-moz-opacity:.5;-khtml-opacity:.5;opacity:.5}#login-overlay .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#login-overlay .wrapper .outer{display:table;width:100%;height:100%}#login-overlay .wrapper .outer .inner{display:table-cell;vertical-align:middle}#login-overlay .wrapper .outer .inner .content{text-align:center;color:white}#noscript{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#ffffff;z-index:12310}#noscript .wrapper{position:absolute;top:0;bottom:0;left:0;right:0}#noscript .wrapper .outer{display:table;width:100%;height:100%}#noscript .wrapper .outer .inner{display:table-cell;vertical-align:middle}#noscript .wrapper .outer .inner .content{text-align:center}
    var errorCredentialsElement = $("#login-error-credentials");
    var errorRateElement = $("#login-error-rate");
        errorCredentialsElement.removeClass("in");
        errorRateElement.removeClass("in");
            .fail((xhr) => {
                if (xhr.status === 429) {
                    errorRateElement.addClass("in");
                } else {
                    errorCredentialsElement.addClass("in");
                }
#login-error-credentials,
#login-error-rate,
            <div id="login-error-credentials" class="alert alert-error" data-test-id="login-error">{{ _('Incorrect username or password. Hint: Both are case sensitive!') }}</div>
            <div id="login-error-rate" class="alert alert-error" data-test-id="login-error-rate">{{ _('You have made too many failed login attempts. Please try again later.') }}</div>