CVE-2022-2888 (Python, published 2022-09-21T12:15Z)
Description: value: 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.
Severity: nan (CVSS v3 base score: 4.4)
Weakness: CWE-613 \u2014 Insufficient Session Expiration
According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
Repository: https://github.com/octoprint/octoprint
Files changed:
src/octoprint/access/users.py
src/octoprint/server/__init__.py
src/octoprint/server/api/__init__.py
src/octoprint/server/util/flask.py
src/octoprint/server/util/sockjs.py
src/octoprint/static/js/app/viewmodels/usersettings.js
src/octoprint/templates/dialogs/usersettings/access.jinja2
tests/server/util/test_flask.py
Commit message: 🔒 Make session handling more secure

* 🔒️ Tie remember_me cookie to password hash

That way when the password gets changed,
existing cookies automatically get
invalidated.

* 🔒️ Invalidate user sessions on password change

This will require a user to log in again after
their password is changed, either by themselves
or through an admin.

This is a slight change in behaviour compared
to before, but makes sure password changes
are enforced across all existing sessions.

* ♻️ remember_key_for_user => signature_key_for_user

* 🔒️ Invalidate sessions after 15min of inactivity

The server has been keeping track of login
session for a while, but now it also only
considers such (active login) sessions fresh
that have been active within the past 15min.

That way, a session cookie's lifetime is now
also restricted server side. An active
socket connection suffices to keep the
session alive, as do any other requests
using the cookie.

As the session tracking happens in-memory,
all sessions will become invalid on server
restart. However, if the remember me cookie
is set, after server restart a passive
login will succeed.

* 🔒️ Limit remember_me validity to 90 days

So far that was only done through the cookie
duration, and with a year to boot. Now we
encode the creation date into the cookie
and check against that, if it exceeds the
cookie duration the cookie is considered
unset.

Will prevent reuse of ancient remember me
cookies (and force the user to login at
least once every 90d).

* 💚 Fix unit tests

New cookie handling requires a faked flask
config object.

Related: https://huntr.dev/bounties/d27d232b-2578-4b32-b3b4-74aabdadf629/

Vulnerable code:
            if user.created + (24 * 60 * 60) < time.monotonic():
                    "Cleaning up user session {} for user {}".format(
                        session, user.get_id()
                    )
    def find_user(self, userid=None, session=None):
                            result.append(self._session_users_by_session[session_id])
    def find_user(self, userid=None, apikey=None, session=None):
        if user is not None:
        self._self_created = time.monotonic()
        self._self_touched = time.monotonic()
from octoprint.server.util.flask import PreemptiveCache
        user = userManager.find_user(userid=id, session=sessionid)
    if user and user.is_active:
from datetime import datetime
            if key.endswith(self.cookie_suffix):
                desuffixed[key[: -len(self.cookie_suffix)]] = value
            else:
                result[key] = value
    def set_cookie(self, key, *args, **kwargs):
            self, key + flask.request.cookie_suffix, *args, **kwargs
from octoprint.access.users import LoginStatusListener
            function process() {
                        process();
                process();
        cookies = request.cookies
                    response.set_cookie("some_key", "some_value", **kwargs)
                        "some_value",
                        response.delete_cookie("some_key", **kwargs)

Fixed code:
            if user.touched + (15 * 60) < time.monotonic():
                    f"Cleaning up user session {session} for user {user.get_id()}"
    def signature_key_for_user(self, username, salt=None):
        return self.create_password_hash(username, salt=salt)

        self._cleanup_sessions()

    def find_user(self, userid=None, session=None, fresh=False):
        self._cleanup_sessions()

                user.touch()
        self._cleanup_sessions()

                            session_user = self._session_users_by_session[session_id]
                            session_user.touch()
                            result.append(session_user)
            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 find_user(self, userid=None, apikey=None, session=None, fresh=False):
        if user is not None or (session and fresh):
        self._self_created = self._self_touched = time.monotonic()
from octoprint.server.util.flask import PreemptiveCache, validate_session_signature
    if session and "usersession.signature" in session:
        sessionsig = session["usersession.signature"]
    else:
        sessionsig = ""

        # 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)
        )
    if (
        user
        and user.is_active
        and (not sessionid or validate_session_signature(sessionsig, id, sessionid))
    ):
        app.config["REMEMBER_COOKIE_DURATION"] = 90 * 24 * 60 * 60  # 90 days
        # REMEMBER_COOKIE_SECURE will be taken care of by our custom cookie handling
    session_signature,
                session["usersession.signature"] = session_signature(
                    username, user.session
                )
import hashlib
import hmac
from datetime import datetime, timedelta
from flask import current_app
from flask_login import COOKIE_NAME as REMEMBER_COOKIE_NAME
from flask_login.utils import decode_cookie, encode_cookie
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")



            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
    def set_cookie(self, key, value="", *args, **kwargs):
        # 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)

            self, key + flask.request.cookie_suffix, value=value, *args, **kwargs
            flask.session["usersession.signature"] = session_signature(
                u.get_id(), u.session
            )


##~~ 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))
from octoprint.access.users import LoginStatusListener, SessionUser
from octoprint.util import RepeatedTimer
        self._keep_alive = RepeatedTimer(
            60, self._keep_alive_callback, condition=lambda: self._authed
        )

    def _keep_alive_callback(self):
        if not self._authed:
            return
        if not isinstance(self._user, SessionUser):
            return
        self._user.touch()

        self._keep_alive.start()

            function saveSettings() {
                        saveSettings();
                saveSettings();
        <p>{% trans %}
            Please note that you will be <strong>logged out immediately</strong> after changing your password and asked to login again.
        {% endtrans %}</p>
import flask
        self.app = flask.Flask("testapp")
        self.app.config["SECRET_KEY"] = "secret"

        with self.app.app_context():
            cookies = request.cookies
        self.app = flask.Flask("testapp")
        self.app.config["SECRET_KEY"] = "secret"

                    with self.app.app_context():
                        response.set_cookie("some_key", "some_value", **kwargs)
                        value="some_value",
                        with self.app.app_context():
                            response.delete_cookie("some_key", **kwargs)