CVE-2022-2822 (published 2022-08-15T11:21Z)

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.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-307
cwe_name: Improper Restriction of Excessive Authentication Attempts
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.
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/octoprint/octoprint
commit_details: repo_url: https://github.com/octoprint/octoprint
author: Gina Häußge
author_date: 2022-08-15 11:21:59+02:00
msg: 🔒️ 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.
repository: repo_url: https://github.com/octoprint/octoprint
repo_name: OctoPrint/OctoPrint
description: OctoPrint is the snappy web interface for your 3D printer!
repo_language: Python
owner: octoprint
file_changes: filename: setup.py
change_type: ModificationType.MODIFY
diff: @@ -43,6 +43,7 @@ core_deps = [
     "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

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -70,6 +70,7 @@ app = Flask("octoprint")
 
 assets = None
 babel = None
+limiter = None
 debug = False
 safe_mode = False
 
@@ -1338,6 +1339,8 @@ class Server:
         timer.start()
 
     def _setup_app(self, app):
+        global limiter
+
         from octoprint.server.util.flask import (
             OctoPrintFlaskRequest,
             OctoPrintFlaskResponse,
@@ -1433,6 +1436,13 @@ class Server:
 
         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

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -281,6 +281,11 @@ def serverStatus():
 
 
 @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:

programming_language: Python
filename: login.css
change_type: ModificationType.MODIFY
diff: @@ -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

programming_language: CSS
filename: login.js
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,8 @@ $(function () {
     };
 
     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");
@@ -28,7 +29,8 @@ $(function () {
         var remember = rememberElement.prop("checked");
 
         overlayElement.addClass("in");
-        errorElement.removeClass("in");
+        errorCredentialsElement.removeClass("in");
+        errorRateElement.removeClass("in");
 
         OctoPrint.browser
             .login(username, password, remember)
@@ -36,7 +38,7 @@ $(function () {
                 ignoreDisconnect = true;
                 window.location.href = REDIRECT_URL;
             })
-            .fail(() => {
+            .fail((xhr) => {
                 usernameElement.val(USER_ID);
                 passwordElement.val("");
 
@@ -47,7 +49,11 @@ $(function () {
                 }
 
                 overlayElement.removeClass("in");
-                errorElement.addClass("in");
+                if (xhr.status === 429) {
+                    errorRateElement.addClass("in");
+                } else {
+                    errorCredentialsElement.addClass("in");
+                }
             });
 
         return false;

programming_language: JavaScript
filename: login.less
change_type: ModificationType.MODIFY
diff: @@ -31,7 +31,8 @@ body {
   }
 }
 
-#login-error,
+#login-error-credentials,
+#login-error-rate,
 #login-offline {
   display: none;
 

programming_language: CSS
filename: login.jinja2
change_type: ModificationType.MODIFY
diff: @@ -65,7 +65,8 @@
         <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>

programming_language: HTML