From cc3d064e644491d8a50d2ed3d59870739e3fd153 Mon Sep 17 00:00:00 2001 From: Finn Stutzenstein Date: Wed, 21 Oct 2026 11:46:44 +0000 Subject: [PATCH] Expire sessions after 30 min idle and 12 h absolute Sessions were only bounded by a process restart or the LRU eviction, so a stolen or fixed session id could stay valid for months. A cookie TTL would not help, since an attacker with a copy of the id ignores it. The store entry now carries _created_at and _last_seen (monotonic time). They are kept out of the session dict as attributes of the Session object, so is_empty() and the app code are unaffected. open_session drops expired entries and returns a fresh, empty session. Co-Authored-By: Claude Opus 5.5 --- max/sessions.py | 46 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/max/sessions.py b/max/sessions.py index ea31493..335740d 100644 --- a/max/sessions.py +++ b/max/sessions.py @@ -1,3 +1,5 @@ +import time + from flask import request from flask.sessions import SessionInterface as FlaskSessionInterface, SessionMixin from itsdangerous import BadSignature, Signer, want_bytes @@ -5,6 +7,14 @@ from werkzeug.datastructures import CallbackDict from max.crypto import get_random_string +# in seconds +SESSION_IDLE_TIMEOUT = 30 * 60 +SESSION_ABSOLUTE_TIMEOUT = 12 * 60 * 60 + +# Keys for the session metadata in the session storage +CREATED_AT_KEY = "_created_at" +LAST_SEEN_KEY = "_last_seen" + class SessionStorage: def __init__(self, maxcap=100): @@ -54,23 +64,33 @@ session_storage = SessionStorage() class Session(CallbackDict, SessionMixin): - def __init__(self, id=None, data=None): + def __init__(self, id=None, data=None, created_at=None, last_seen=None): def on_update(self): self.modified = True if not self.id: self.set_new_id() self.id = id + self.created_at = created_at + self.last_seen = last_seen self.modified = False CallbackDict.__init__(self, data, on_update) def is_empty(self): return len(self) == 0 + def is_expired(self, now): + return ( + now - self.created_at > SESSION_ABSOLUTE_TIMEOUT + or now - self.last_seen > SESSION_IDLE_TIMEOUT + ) + def clear(self): """Removes all data, including the entry in the session storage""" del session_storage[self.id] self.id = None + self.created_at = None + self.last_seen = None super().clear() def set_new_id(self): @@ -87,7 +107,14 @@ class SessionInterface(FlaskSessionInterface): return app.config["SESSION_COOKIE_NAME"] def make_session_object(self, session_id=None, data=None): - return Session(session_id, data=data) + created_at = last_seen = None + if data is not None: + data = dict(data) # do not modify the entry in the session storage + created_at = data.pop(CREATED_AT_KEY) + last_seen = data.pop(LAST_SEEN_KEY) + return Session( + session_id, data=data, created_at=created_at, last_seen=last_seen + ) def open_session(self, app, request): session_id = request.cookies.get(self.get_cookie_name(app)) @@ -107,7 +134,11 @@ class SessionInterface(FlaskSessionInterface): session_id = session_id.decode("utf-8", "strict") data = session_storage[session_id] - return self.make_session_object(session_id, data=data) + session = self.make_session_object(session_id, data=data) + if data is not None and session.is_expired(time.monotonic()): + del session_storage[session_id] + return self.make_session_object() + return session def save_session(self, app, session, response): domain = self.get_cookie_domain(app) @@ -123,7 +154,14 @@ class SessionInterface(FlaskSessionInterface): httponly = self.get_cookie_httponly(app) secure = self.get_cookie_secure(app) expires = self.get_expiration_time(app, session) - session_storage[session.id] = dict(session) + session.last_seen = time.monotonic() + if session.created_at is None: + session.created_at = session.last_seen + session_storage[session.id] = { + **session, + CREATED_AT_KEY: session.created_at, + LAST_SEEN_KEY: session.last_seen, + } session_id = ( self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8") )