mirror of https://github.com/pallets/flask.git
enable secret key rotation
This commit is contained in:
parent
7522c4bcdb
commit
e13373f838
|
@ -20,6 +20,9 @@ Unreleased
|
||||||
- ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
|
- ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
|
||||||
``load_dotenv`` loads default files in addition to a path unless
|
``load_dotenv`` loads default files in addition to a path unless
|
||||||
``load_defaults=False`` is passed. :issue:`5628`
|
``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
|
Version 3.0.3
|
||||||
|
|
|
@ -125,6 +125,22 @@ The following configuration values are used internally by Flask:
|
||||||
|
|
||||||
Default: ``None``
|
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
|
.. py:data:: SESSION_COOKIE_NAME
|
||||||
|
|
||||||
The name of the session cookie. Can be changed in case you already have a
|
The name of the session cookie. Can be changed in case you already have a
|
||||||
|
|
|
@ -79,7 +79,7 @@ source = ["src", "*/site-packages"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.9"
|
python_version = "3.9"
|
||||||
files = ["src/flask", "tests/typing"]
|
files = ["src/flask", "tests/type_check"]
|
||||||
show_error_codes = true
|
show_error_codes = true
|
||||||
pretty = true
|
pretty = true
|
||||||
strict = true
|
strict = true
|
||||||
|
@ -95,7 +95,7 @@ ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
pythonVersion = "3.9"
|
pythonVersion = "3.9"
|
||||||
include = ["src/flask", "tests/typing"]
|
include = ["src/flask", "tests/type_check"]
|
||||||
typeCheckingMode = "basic"
|
typeCheckingMode = "basic"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|
|
@ -180,6 +180,7 @@ class Flask(App):
|
||||||
"TESTING": False,
|
"TESTING": False,
|
||||||
"PROPAGATE_EXCEPTIONS": None,
|
"PROPAGATE_EXCEPTIONS": None,
|
||||||
"SECRET_KEY": None,
|
"SECRET_KEY": None,
|
||||||
|
"SECRET_KEY_FALLBACKS": None,
|
||||||
"PERMANENT_SESSION_LIFETIME": timedelta(days=31),
|
"PERMANENT_SESSION_LIFETIME": timedelta(days=31),
|
||||||
"USE_X_SENDFILE": False,
|
"USE_X_SENDFILE": False,
|
||||||
"SERVER_NAME": None,
|
"SERVER_NAME": None,
|
||||||
|
|
|
@ -315,14 +315,20 @@ class SecureCookieSessionInterface(SessionInterface):
|
||||||
def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
|
def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
|
||||||
if not app.secret_key:
|
if not app.secret_key:
|
||||||
return None
|
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(
|
return URLSafeTimedSerializer(
|
||||||
app.secret_key,
|
keys, # type: ignore[arg-type]
|
||||||
salt=self.salt,
|
salt=self.salt,
|
||||||
serializer=self.serializer,
|
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:
|
def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None:
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import gc
|
import gc
|
||||||
import re
|
import re
|
||||||
|
import typing as t
|
||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
import weakref
|
import weakref
|
||||||
|
@ -369,6 +370,27 @@ def test_missing_session(app):
|
||||||
expect_exception(flask.session.pop, "foo")
|
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):
|
def test_session_expiration(app, client):
|
||||||
permanent = True
|
permanent = True
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue