VYPR
Moderate severityNVD Advisory· Published Sep 21, 2022· Updated May 28, 2025

Insufficient Session Expiration in octoprint/octoprint

CVE-2022-2888

Description

If an attacker comes into the possession of a victim's OctoPrint session cookie through whatever means, the attacker can use this cookie to authenticate as long as the victim's account exists.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

CVE-2022-2888 allows an attacker possessing a victim's OctoPrint session cookie to impersonate them for the lifetime of their account.

Vulnerability

Overview

CVE-2022-2888 is a session management weakness in OctoPrint, a web interface for 3D printers. The vulnerability stems from the use of static or easily predicted session cookies that, once obtained, remain valid indefinitely as long as the victim's user account exists. This means an attacker who acquires a session cookie—via cross-site scripting, network interception, or other means—can reuse that cookie to authenticate as the victim without any additional verification or re-authentication step [1][2].

Exploitation

Prerequisites

The attacker must first gain possession of the victim's session cookie through any external method (e.g., stealing from the browser, capturing over an insecure network, or via a different vulnerability). No further authentication or authorization is required once the cookie is obtained; the attacker can directly use the cookie to impersonate the victim within OctoPrint. The attack does not require the victim to be logged in at the time of use—the cookie remains valid as long as the victim's account is active [1].

Impact

Successful exploitation grants the attacker the same privileges as the victim user. In the context of OctoPrint, this could allow manipulation of printer operations, viewing or modifying stored G-code files, altering printer settings, and potentially impacting the physical printer. The severity is elevated because session cookies are the primary authentication mechanism, and this weakness undermines trust in the session layer [1][2].

Mitigation

The OctoPrint project addressed this issue in a commit (40e6217ac1a85cc5ed592873ae49db01d3005da4) that implements cryptographic signing of the remember-me cookie using HMAC and a per-user signature key, along with a timestamp and configurable duration to enforce expiration [2]. Users should update to the latest patched version of OctoPrint to ensure that session cookies are cryptographically bound and time-limited, preventing indefinite reuse even if a cookie is stolen [2][3].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
OctoPrintPyPI
< 1.8.31.8.3

Affected products

2
  • ghsa-coords
    Range: < 1.8.3
  • octoprint/octoprint/octoprintv5
    Range: unspecified

Patches

1
40e6217ac1a8

🔒 Make session handling more secure

https://github.com/octoprint/octoprintGina HäußgeSep 1, 2022via ghsa
8 files changed · +175 28
  • src/octoprint/access/users.py+27 10 modified
    @@ -127,11 +127,9 @@ def _cleanup_sessions(self):
             for session, user in list(self._session_users_by_session.items()):
                 if not isinstance(user, SessionUser):
                     continue
    -            if user.created + (24 * 60 * 60) < time.monotonic():
    +            if user.touched + (15 * 60) < time.monotonic():
                     self._logger.info(
    -                    "Cleaning up user session {} for user {}".format(
    -                        session, user.get_id()
    -                    )
    +                    f"Cleaning up user session {session} for user {user.get_id()}"
                     )
                     self.logout_user(user, stale=True)
     
    @@ -176,6 +174,9 @@ def check_password(self, username, password):
                     # old hash doesn't match either, wrong password
                     return False
     
    +    def signature_key_for_user(self, username, salt=None):
    +        return self.create_password_hash(username, salt=salt)
    +
         def add_user(self, username, password, active, permissions, groups, overwrite=False):
             pass
     
    @@ -227,29 +228,38 @@ def remove_user(self, username):
                 del self._sessionids_by_userid[username]
     
         def validate_user_session(self, userid, session):
    +        self._cleanup_sessions()
    +
             if session in self._session_users_by_session:
                 user = self._session_users_by_session[session]
                 return userid == user.get_id()
     
             return False
     
    -    def find_user(self, userid=None, session=None):
    +    def find_user(self, userid=None, session=None, fresh=False):
    +        self._cleanup_sessions()
    +
             if session is not None and session in self._session_users_by_session:
                 user = self._session_users_by_session[session]
                 if userid is None or userid == user.get_id():
    +                user.touch()
                     return user
     
             return None
     
         def find_sessions_for(self, matcher):
    +        self._cleanup_sessions()
    +
             result = []
             for user in self.get_all_users():
                 if matcher(user):
                     try:
                         session_ids = self._sessionids_by_userid[user.get_id()]
                         for session_id in session_ids:
                             try:
    -                            result.append(self._session_users_by_session[session_id])
    +                            session_user = self._session_users_by_session[session_id]
    +                            session_user.touch()
    +                            result.append(session_user)
                             except KeyError:
                                 # unknown session after all
                                 continue
    @@ -780,6 +790,14 @@ def change_user_password(self, username, password):
                 self._dirty = True
                 self._save()
     
    +            self._trigger_on_user_modified(user)
    +
    +    def signature_key_for_user(self, username, salt=None):
    +        if username not in self._users:
    +            raise UnknownUser(username)
    +        user = self._users[username]
    +        return UserManager.create_password_hash(username + user._passwordHash, salt=salt)
    +
         def change_user_setting(self, username, key, value):
             if username not in self._users:
                 raise UnknownUser(username)
    @@ -845,10 +863,10 @@ def remove_user(self, username):
             self._dirty = True
             self._save()
     
    -    def find_user(self, userid=None, apikey=None, session=None):
    +    def find_user(self, userid=None, apikey=None, session=None, fresh=False):
             user = UserManager.find_user(self, userid=userid, session=session)
     
    -        if user is not None:
    +        if user is not None or (session and fresh):
                 return user
     
             if userid is not None:
    @@ -1383,8 +1401,7 @@ def __init__(self, user):
             wrapt.ObjectProxy.__init__(self, user)
     
             self._self_session = "".join("%02X" % z for z in bytes(uuid.uuid4().bytes))
    -        self._self_created = time.monotonic()
    -        self._self_touched = time.monotonic()
    +        self._self_created = self._self_touched = time.monotonic()
     
         @property
         def session(self):
    
  • src/octoprint/server/api/__init__.py+4 0 modified
    @@ -38,6 +38,7 @@
         limit,
         no_firstrun_access,
         passive_login,
    +    session_signature,
     )
     from octoprint.settings import settings as s
     from octoprint.settings import valid_boolean_trues
    @@ -312,6 +313,9 @@ def login():
     
                     user = octoprint.server.userManager.login_user(user)
                     session["usersession.id"] = user.session
    +                session["usersession.signature"] = session_signature(
    +                    username, user.session
    +                )
                     g.user = user
     
                     login_user(user, remember=remember)
    
  • src/octoprint/server/__init__.py+18 3 modified
    @@ -130,7 +130,7 @@
         loginFromApiKeyRequestHandler,
         requireLoginRequestHandler,
     )
    -from octoprint.server.util.flask import PreemptiveCache
    +from octoprint.server.util.flask import PreemptiveCache, validate_session_signature
     from octoprint.settings import settings
     
     VERSION = __version__
    @@ -187,12 +187,25 @@ def load_user(id):
         else:
             sessionid = None
     
    +    if session and "usersession.signature" in session:
    +        sessionsig = session["usersession.signature"]
    +    else:
    +        sessionsig = ""
    +
         if sessionid:
    -        user = userManager.find_user(userid=id, session=sessionid)
    +        # session["_fresh"] is False if the session comes from a remember me cookie,
    +        # True if it came from a use of the login dialog
    +        user = userManager.find_user(
    +            userid=id, session=sessionid, fresh=session.get("_fresh", False)
    +        )
         else:
             user = userManager.find_user(userid=id)
     
    -    if user and user.is_active:
    +    if (
    +        user
    +        and user.is_active
    +        and (not sessionid or validate_session_signature(sessionsig, id, sessionid))
    +    ):
             return user
     
         return None
    @@ -1366,7 +1379,9 @@ def _setup_app(self, app):
     
             app.config["TEMPLATES_AUTO_RELOAD"] = True
             app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
    +        app.config["REMEMBER_COOKIE_DURATION"] = 90 * 24 * 60 * 60  # 90 days
             app.config["REMEMBER_COOKIE_HTTPONLY"] = True
    +        # REMEMBER_COOKIE_SECURE will be taken care of by our custom cookie handling
     
             # we must not set this before TEMPLATES_AUTO_RELOAD is set to True or that won't take
             app.debug = self._debug
    
  • src/octoprint/server/util/flask.py+91 7 modified
    @@ -5,11 +5,13 @@
     __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License"
     
     import functools
    +import hashlib
    +import hmac
     import logging
     import os
     import threading
     import time
    -from datetime import datetime
    +from datetime import datetime, timedelta
     from typing import Union
     
     import flask
    @@ -23,6 +25,9 @@
     import webassets.updater
     import webassets.utils
     from cachelib import BaseCache
    +from flask import current_app
    +from flask_login import COOKIE_NAME as REMEMBER_COOKIE_NAME
    +from flask_login.utils import decode_cookie, encode_cookie
     from werkzeug.local import LocalProxy
     from werkzeug.utils import cached_property
     
    @@ -422,6 +427,49 @@ def host_to_server_and_port(host, scheme):
     # ~~ request and response versions
     
     
    +def encode_remember_me_cookie(value):
    +    from octoprint.server import userManager
    +
    +    name = value.split("|")[0]
    +    try:
    +        remember_key = userManager.signature_key_for_user(
    +            name, salt=current_app.config["SECRET_KEY"]
    +        )
    +        timestamp = datetime.utcnow().timestamp()
    +        return encode_cookie(f"{name}|{timestamp}", key=remember_key)
    +    except Exception:
    +        pass
    +
    +    return ""
    +
    +
    +def decode_remember_me_cookie(value):
    +    from octoprint.server import userManager
    +
    +    parts = value.split("|")
    +    if len(parts) == 3:
    +        name, created, _ = parts
    +
    +        try:
    +            # valid signature?
    +            signature_key = userManager.signature_key_for_user(
    +                name, salt=current_app.config["SECRET_KEY"]
    +            )
    +            cookie = decode_cookie(value, key=signature_key)
    +            if cookie:
    +                # still valid?
    +                if (
    +                    datetime.fromtimestamp(float(created))
    +                    + timedelta(seconds=current_app.config["REMEMBER_COOKIE_DURATION"])
    +                    > datetime.utcnow()
    +                ):
    +                    return encode_cookie(name)
    +        except Exception:
    +            pass
    +
    +    raise ValueError("Invalid remember me cookie")
    +
    +
     class OctoPrintFlaskRequest(flask.Request):
         environment_wrapper = staticmethod(lambda x: x)
     
    @@ -437,10 +485,23 @@ def cookies(self):
             result = {}
             desuffixed = {}
             for key, value in cookies.items():
    -            if key.endswith(self.cookie_suffix):
    -                desuffixed[key[: -len(self.cookie_suffix)]] = value
    -            else:
    -                result[key] = value
    +
    +            def process_value(k, v):
    +                if k == current_app.config.get(
    +                    "REMEMBER_COOKIE_NAME", REMEMBER_COOKIE_NAME
    +                ):
    +                    return decode_remember_me_cookie(v)
    +                return v
    +
    +            try:
    +                if key.endswith(self.cookie_suffix):
    +                    key = key[: -len(self.cookie_suffix)]
    +                    desuffixed[key] = process_value(key, value)
    +                else:
    +                    result[key] = process_value(key, value)
    +            except ValueError:
    +                # ignore broken cookies
    +                pass
     
             result.update(desuffixed)
             return result
    @@ -471,7 +532,7 @@ def cookie_suffix(self):
     
     
     class OctoPrintFlaskResponse(flask.Response):
    -    def set_cookie(self, key, *args, **kwargs):
    +    def set_cookie(self, key, value="", *args, **kwargs):
             # restrict cookie path to script root
             kwargs["path"] = flask.request.script_root + kwargs.get("path", "/")
     
    @@ -490,9 +551,13 @@ def set_cookie(self, key, *args, **kwargs):
             # set secure if necessary
             kwargs["secure"] = settings().getBoolean(["server", "cookies", "secure"])
     
    +        # tie account properties to remember me cookie (e.g. current password hash)
    +        if key == current_app.config.get("REMEMBER_COOKIE_NAME", REMEMBER_COOKIE_NAME):
    +            value = encode_remember_me_cookie(value)
    +
             # add request specific cookie suffix to name
             flask.Response.set_cookie(
    -            self, key + flask.request.cookie_suffix, *args, **kwargs
    +            self, key + flask.request.cookie_suffix, value=value, *args, **kwargs
             )
     
         def delete_cookie(self, key, path="/", domain=None):
    @@ -608,6 +673,9 @@ def login(u):
             )
             if hasattr(u, "session"):
                 flask.session["usersession.id"] = u.session
    +            flask.session["usersession.signature"] = session_signature(
    +                u.get_id(), u.session
    +            )
             flask.g.user = u
     
             eventManager().fire(Events.USER_LOGGED_IN, payload={"username": u.get_id()})
    @@ -1832,3 +1900,19 @@ def default(self, obj):
                 return JsonEncoding.encode(obj)
             except TypeError:
                 return flask.json.JSONEncoder.default(self, obj)
    +
    +
    +##~~ Session signing
    +
    +
    +def session_signature(user, session):
    +    from octoprint.server import userManager
    +
    +    key = userManager.signature_key_for_user(user, salt=current_app.config["SECRET_KEY"])
    +    return hmac.new(
    +        key.encode("utf-8"), session.encode("utf-8"), hashlib.sha512
    +    ).hexdigest()
    +
    +
    +def validate_session_signature(sig, user, session):
    +    return hmac.compare_digest(sig, session_signature(user, session))
    
  • src/octoprint/server/util/sockjs.py+15 1 modified
    @@ -21,9 +21,10 @@
     import octoprint.vendor.sockjs.tornado.util
     from octoprint.access.groups import GroupChangeListener
     from octoprint.access.permissions import Permissions
    -from octoprint.access.users import LoginStatusListener
    +from octoprint.access.users import LoginStatusListener, SessionUser
     from octoprint.events import Events
     from octoprint.settings import settings
    +from octoprint.util import RepeatedTimer
     from octoprint.util.json import dumps as json_dumps
     from octoprint.util.version import get_python_version_string
     
    @@ -173,13 +174,24 @@ def __init__(
             self._subscriptions_active = False
             self._subscriptions = {"state": False, "plugins": [], "events": []}
     
    +        self._keep_alive = RepeatedTimer(
    +            60, self._keep_alive_callback, condition=lambda: self._authed
    +        )
    +
         @staticmethod
         def _get_remote_address(info):
             forwarded_for = info.headers.get("X-Forwarded-For")
             if forwarded_for is not None:
                 return forwarded_for.split(",")[0]
             return info.ip
     
    +    def _keep_alive_callback(self):
    +        if not self._authed:
    +            return
    +        if not isinstance(self._user, SessionUser):
    +            return
    +        self._user.touch()
    +
         def __str__(self):
             if self._remoteAddress:
                 return f"{self!r} connected to {self._remoteAddress}"
    @@ -716,6 +728,8 @@ def _on_login(self, user):
             )
             self._authed = True
     
    +        self._keep_alive.start()
    +
             for name, hook in self._authed_hooks.items():
                 try:
                     hook(self, self._user)
    
  • src/octoprint/static/js/app/viewmodels/usersettings.js+3 3 modified
    @@ -88,7 +88,7 @@ $(function () {
     
                 self.userSettingsDialog.trigger("beforeSave");
     
    -            function process() {
    +            function saveSettings() {
                     var settings = {
                         interface: {
                             language: self.interface_language()
    @@ -110,15 +110,15 @@ $(function () {
                             self.access_currentPassword()
                         )
                         .done(function () {
    -                        process();
    +                        saveSettings();
                         })
                         .fail(function (xhr) {
                             if (xhr.status === 403) {
                                 self.access_currentPasswordMismatch(true);
                             }
                         });
                 } else {
    -                process();
    +                saveSettings();
                 }
             };
     
    
  • src/octoprint/templates/dialogs/usersettings/access.jinja2+3 0 modified
    @@ -24,6 +24,9 @@
                     <span class="help-inline" data-bind="visible: passwordMismatch()">{{ _('Passwords do not match') }}</span>
                 </div>
             </div>
    +        <p>{% trans %}
    +            Please note that you will be <strong>logged out immediately</strong> after changing your password and asked to login again.
    +        {% endtrans %}</p>
         </fieldset>
         <fieldset>
             <legend>{{ _('API Key') }}</legend>
    
  • tests/server/util/test_flask.py+14 4 modified
    @@ -10,6 +10,7 @@
     import unittest
     from unittest import mock
     
    +import flask
     from ddt import data, ddt, unpack
     
     from octoprint.server.util.flask import (
    @@ -424,6 +425,9 @@ class OctoPrintFlaskRequestTest(unittest.TestCase):
         def setUp(self):
             self.orig_environment_wrapper = OctoPrintFlaskRequest.environment_wrapper
     
    +        self.app = flask.Flask("testapp")
    +        self.app.config["SECRET_KEY"] = "secret"
    +
         def tearDown(self):
             OctoPrintFlaskRequest.environment_wrapper = staticmethod(
                 self.orig_environment_wrapper
    @@ -470,7 +474,8 @@ def test_cookies(self):
     
             request = OctoPrintFlaskRequest(environ)
     
    -        cookies = request.cookies
    +        with self.app.app_context():
    +            cookies = request.cookies
             self.assertDictEqual(
                 {
                     "postfixed": "postfixed_value",
    @@ -495,6 +500,9 @@ def setUp(self):
             self.settings = mock.MagicMock()
             self.settings_getter.return_value = self.settings
     
    +        self.app = flask.Flask("testapp")
    +        self.app.config["SECRET_KEY"] = "secret"
    +
         def tearDown(self):
             self.settings_patcher.stop()
     
    @@ -545,13 +553,14 @@ def test_cookie_set_and_delete(
     
                     # test set_cookie
                     with mock.patch("flask.Response.set_cookie") as set_cookie_mock:
    -                    response.set_cookie("some_key", "some_value", **kwargs)
    +                    with self.app.app_context():
    +                        response.set_cookie("some_key", "some_value", **kwargs)
     
                         # set_cookie should have key and path values adjusted
                         set_cookie_mock.assert_called_once_with(
                             response,
                             "some_key" + expected_suffix,
    -                        "some_value",
    +                        value="some_value",
                             path=expected_path_set,
                             secure=secure,
                             samesite=expected_samesite,
    @@ -560,7 +569,8 @@ def test_cookie_set_and_delete(
                     # test delete_cookie
                     with mock.patch("flask.Response.set_cookie") as set_cookie_mock:
                         with mock.patch("flask.Response.delete_cookie") as delete_cookie_mock:
    -                        response.delete_cookie("some_key", **kwargs)
    +                        with self.app.app_context():
    +                            response.delete_cookie("some_key", **kwargs)
     
                             # delete_cookie internally calls set_cookie - so our delete_cookie call still uses the non modified
                             # key and path values, set_cookie will translate those (as tested above)
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.