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 <noreply@anthropic.com>
This commit is contained in:
finnandClaude Opus 5.5 committed 2026-10-21 11:46:44 +00:00
1 parent ca023e705c
commit cc3d064e64
1 file changed
+42 -4
+42 -4
View File
@@ -1,3 +1,5 @@
import time
from flask import request from flask import request
from flask.sessions import SessionInterface as FlaskSessionInterface, SessionMixin from flask.sessions import SessionInterface as FlaskSessionInterface, SessionMixin
from itsdangerous import BadSignature, Signer, want_bytes from itsdangerous import BadSignature, Signer, want_bytes
@@ -5,6 +7,14 @@ from werkzeug.datastructures import CallbackDict
from max.crypto import get_random_string 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: class SessionStorage:
def __init__(self, maxcap=100): def __init__(self, maxcap=100):
@@ -54,23 +64,33 @@ session_storage = SessionStorage()
class Session(CallbackDict, SessionMixin): 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): def on_update(self):
self.modified = True self.modified = True
if not self.id: if not self.id:
self.set_new_id() self.set_new_id()
self.id = id self.id = id
self.created_at = created_at
self.last_seen = last_seen
self.modified = False self.modified = False
CallbackDict.__init__(self, data, on_update) CallbackDict.__init__(self, data, on_update)
def is_empty(self): def is_empty(self):
return len(self) == 0 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): def clear(self):
"""Removes all data, including the entry in the session storage""" """Removes all data, including the entry in the session storage"""
del session_storage[self.id] del session_storage[self.id]
self.id = None self.id = None
self.created_at = None
self.last_seen = None
super().clear() super().clear()
def set_new_id(self): def set_new_id(self):
@@ -87,7 +107,14 @@ class SessionInterface(FlaskSessionInterface):
return app.config["SESSION_COOKIE_NAME"] return app.config["SESSION_COOKIE_NAME"]
def make_session_object(self, session_id=None, data=None): 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): def open_session(self, app, request):
session_id = request.cookies.get(self.get_cookie_name(app)) 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") session_id = session_id.decode("utf-8", "strict")
data = session_storage[session_id] 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): def save_session(self, app, session, response):
domain = self.get_cookie_domain(app) domain = self.get_cookie_domain(app)
@@ -123,7 +154,14 @@ class SessionInterface(FlaskSessionInterface):
httponly = self.get_cookie_httponly(app) httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app) secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session) 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 = ( session_id = (
self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8") self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8")
) )