Skip to content

Commit

Permalink
enable secret key rotation
Browse files Browse the repository at this point in the history
  • Loading branch information
davidism committed Nov 8, 2024
1 parent 7522c4b commit e13373f
Show file tree
Hide file tree
Showing 9 changed files with 55 additions and 7 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src/flask/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions src/flask/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import gc
import re
import typing as t
import uuid
import warnings
import weakref
Expand Down Expand Up @@ -369,6 +370,27 @@ def expect_exception(f, *args, **kwargs):
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

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.

0 comments on commit e13373f

Please sign in to comment.