From e13373f838ab34027c5a80e16a6cb8262d41eab7 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 8 Nov 2024 08:09:01 -0800 Subject: [PATCH] enable secret key rotation --- CHANGES.rst | 3 +++ docs/config.rst | 16 ++++++++++++++ pyproject.toml | 4 ++-- src/flask/app.py | 1 + src/flask/sessions.py | 16 +++++++++----- tests/test_basic.py | 22 +++++++++++++++++++ .../typing_app_decorators.py | 0 .../typing_error_handler.py | 0 tests/{typing => type_check}/typing_route.py | 0 9 files changed, 55 insertions(+), 7 deletions(-) rename tests/{typing => type_check}/typing_app_decorators.py (100%) rename tests/{typing => type_check}/typing_error_handler.py (100%) rename tests/{typing => type_check}/typing_route.py (100%) diff --git a/CHANGES.rst b/CHANGES.rst index 8c19cafa..5a65bf36 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,9 @@ Unreleased - ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files. ``load_dotenv`` loads default files in addition to a path unless ``load_defaults=False`` is passed. :issue:`5628` +- Support key rotation with the ``SECRET_KEY_FALLBACKS`` config, a list of old + secret keys that can still be used for unsigning. Extensions will need to + add support. :issue:`5621` Version 3.0.3 diff --git a/docs/config.rst b/docs/config.rst index c17fc932..51b795f7 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -125,6 +125,22 @@ The following configuration values are used internally by Flask: Default: ``None`` +.. py:data:: SECRET_KEY_FALLBACKS + + A list of old secret keys that can still be used for unsigning, most recent + first. This allows a project to implement key rotation without invalidating + active sessions or other recently-signed secrets. + + Keys should be removed after an appropriate period of time, as checking each + additional key adds some overhead. + + Flask's built-in secure cookie session supports this. Extensions that use + :data:`SECRET_KEY` may not support this yet. + + Default: ``None`` + + .. versionadded:: 3.1 + .. py:data:: SESSION_COOKIE_NAME The name of the session cookie. Can be changed in case you already have a diff --git a/pyproject.toml b/pyproject.toml index f2d33c2d..80c88242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ source = ["src", "*/site-packages"] [tool.mypy] python_version = "3.9" -files = ["src/flask", "tests/typing"] +files = ["src/flask", "tests/type_check"] show_error_codes = true pretty = true strict = true @@ -95,7 +95,7 @@ ignore_missing_imports = true [tool.pyright] pythonVersion = "3.9" -include = ["src/flask", "tests/typing"] +include = ["src/flask", "tests/type_check"] typeCheckingMode = "basic" [tool.ruff] diff --git a/src/flask/app.py b/src/flask/app.py index 5133052f..c4f309f3 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -180,6 +180,7 @@ class Flask(App): "TESTING": False, "PROPAGATE_EXCEPTIONS": None, "SECRET_KEY": None, + "SECRET_KEY_FALLBACKS": None, "PERMANENT_SESSION_LIFETIME": timedelta(days=31), "USE_X_SENDFILE": False, "SERVER_NAME": None, diff --git a/src/flask/sessions.py b/src/flask/sessions.py index beb2f3f3..c2e6a852 100644 --- a/src/flask/sessions.py +++ b/src/flask/sessions.py @@ -315,14 +315,20 @@ class SecureCookieSessionInterface(SessionInterface): def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None: if not app.secret_key: return None - signer_kwargs = dict( - key_derivation=self.key_derivation, digest_method=self.digest_method - ) + + keys: list[str | bytes] = [app.secret_key] + + if fallbacks := app.config["SECRET_KEY_FALLBACKS"]: + keys.extend(fallbacks) + return URLSafeTimedSerializer( - app.secret_key, + keys, # type: ignore[arg-type] salt=self.salt, serializer=self.serializer, - signer_kwargs=signer_kwargs, + signer_kwargs={ + "key_derivation": self.key_derivation, + "digest_method": self.digest_method, + }, ) def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None: diff --git a/tests/test_basic.py b/tests/test_basic.py index 1fc0d67a..842321ce 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,6 @@ import gc import re +import typing as t import uuid import warnings import weakref @@ -369,6 +370,27 @@ def test_missing_session(app): expect_exception(flask.session.pop, "foo") +def test_session_secret_key_fallbacks(app, client) -> None: + @app.post("/") + def set_session() -> str: + flask.session["a"] = 1 + return "" + + @app.get("/") + def get_session() -> dict[str, t.Any]: + return dict(flask.session) + + # Set session with initial secret key + client.post() + assert client.get().json == {"a": 1} + # Change secret key, session can't be loaded and appears empty + app.secret_key = "new test key" + assert client.get().json == {} + # Add initial secret key as fallback, session can be loaded + app.config["SECRET_KEY_FALLBACKS"] = ["test key"] + assert client.get().json == {"a": 1} + + def test_session_expiration(app, client): permanent = True diff --git a/tests/typing/typing_app_decorators.py b/tests/type_check/typing_app_decorators.py similarity index 100% rename from tests/typing/typing_app_decorators.py rename to tests/type_check/typing_app_decorators.py diff --git a/tests/typing/typing_error_handler.py b/tests/type_check/typing_error_handler.py similarity index 100% rename from tests/typing/typing_error_handler.py rename to tests/type_check/typing_error_handler.py diff --git a/tests/typing/typing_route.py b/tests/type_check/typing_route.py similarity index 100% rename from tests/typing/typing_route.py rename to tests/type_check/typing_route.py