Skip to content

Commit ff54d79

Browse files
authored
Merge pull request #4026 from plotly/feat/platform-streaming-env
Add DASH_SECRET_KEY and DASH_SHARED_STORAGE platform hooks
2 parents 2054db5 + f4770ad commit ff54d79

11 files changed

Lines changed: 535 additions & 41 deletions

File tree

‎.ai/ARCHITECTURE.md‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,8 @@ Special handling for Colab:
752752
| `DASH_PRUNE_ERRORS` | Simplify tracebacks |
753753
| `HOST` | Server host |
754754
| `PORT` | Server port |
755+
| `DASH_SECRET_KEY` | Signing secret for page, background and stream tokens when `server.secret_key` is unset |
756+
| `DASH_SHARED_STORAGE` | Shared-storage backend when `shared_storage=` is not passed (see Shared Storage) |
755757

756758
## Stores and Client-Side State
757759

@@ -997,6 +999,20 @@ election only reaches processes in the same network + filesystem namespace.
997999
This works at 1 pod and fragments silently once it scales — use
9981000
`RedisSharedStorage` (one Redis shared by all pods) instead.
9991001
1002+
A hosting platform can switch the backend without editing the app through
1003+
`DASH_SHARED_STORAGE` (`_shared_storage/_env.py`), read only when the app did
1004+
not pass `shared_storage=` (the default is a sentinel, so an explicit argument,
1005+
`None` included, always wins): `local`, `none`, `diskcache:///abs/path`, or a
1006+
`redis://` / `rediss://` URL. A `key_prefix` query parameter on the Redis URL
1007+
(`redis://host/0?key_prefix=myapp`) sets `RedisSharedStorage(key_prefix=)` and
1008+
is stripped before the URL reaches redis-py; the diskcache path is
1009+
percent-decoded. `cluster://` is reserved and raises; anything else raises
1010+
`InvalidConfig` at construction, without echoing the value (it can hold
1011+
credentials). The value becomes a zero-argument
1012+
factory, so nothing is built or connected until `app.shared_storage` is first
1013+
read, but a missing extra (`dash[redis]`, `dash[diskcache]`) fails at
1014+
construction with the backend's own `ImportError`.
1015+
10001016
### Custom and Out-of-Tree Backends
10011017
10021018
`BaseSharedStorage` is the stable, public extension point. A backend — shipped
@@ -1035,6 +1051,7 @@ same way as a built-in: `Dash(shared_storage=PostgresSharedStorage(...))`.
10351051
| `_shared_storage/_polling.py` | `PollingSubscription`: shared poll-loop subscription for the diskcache/Redis backends |
10361052
| `_shared_storage/_transport.py` | Length-prefixed, token-gated socket transport (local backend) |
10371053
| `_shared_storage/_codec.py` | msgspec msgpack codec (data-only) |
1054+
| `_shared_storage/_env.py` | `DASH_SHARED_STORAGE` parsing |
10381055
| `dash.py` | `shared_storage` constructor arg + lazy `app.shared_storage` property |
10391056
| `_callback_context.py` | `dash.ctx.shared_storage` accessor |
10401057
@@ -1405,8 +1422,15 @@ The connection id is never chosen by the client: every stream request rides on
14051422
`?endId=`, the server-signed per-page-load token, and the backend derives the
14061423
id from it (`get_stream_connection_id`), answering 403 when it is missing or
14071424
forged -- otherwise a client could read or inject into another page's topic.
1408-
Across worker processes every worker must resolve the same signing secret
1409-
(`secret_key`).
1425+
Across worker processes every worker must resolve the same signing secret.
1426+
`_get_signing_secret` resolves, in order: `server.secret_key`, then
1427+
`DASH_SECRET_KEY` (for Dash's signing only, never copied onto
1428+
`server.secret_key`, so Flask sessions are untouched), then a secret persisted
1429+
in the background-callback store, then a per-process random one. With the last,
1430+
tokens only verify on the worker that issued them: stream requests 403 on the
1431+
other workers, and the first failure in each process logs a warning pointing at
1432+
`DASH_SECRET_KEY` (`_warn_unverified_stream_token`). A request with no token at
1433+
all is not logged.
14101434
14111435
Each run of streams (from the first stream after idle until none is in flight)
14121436
also carries `&downlinkId=`, picked fresh by the client, and the connection id

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
55
## [Unreleased]
66

77
### Added
8+
- [#4026](https://github.com/plotly/dash/pull/4026) Add `DASH_SECRET_KEY` so a hosting platform can give every worker and pod the same signing secret for page, background and stream tokens without editing the app (`server.secret_key` still wins and is not changed).
9+
- [#4026](https://github.com/plotly/dash/pull/4026) Add `DASH_SHARED_STORAGE` to pick the shared-storage backend when the app does not pass `shared_storage=`: `local`, `none`, `diskcache:///abs/path`, or a `redis://` / `rediss://` URL.
810
- [#4024](https://github.com/plotly/dash/pull/4024) Add support for social media meta tags in regular Dash apps using `app.title`, `app.description`, and `app.image`.
911
- [#3976](https://github.com/plotly/dash/pull/3976) Add a new `scrollToTop` prop to `dcc.Link` to control whether the page scrolls to the top after client-side navigation. It defaults to `True` to preserve the existing behavior. Fixes [#3974](https://github.com/plotly/dash/issues/3974).
1012
- [#3947](https://github.com/plotly/dash/pull/3947) Make `plotly-cloud` a default install dependency of Dash instead of an optional extra, so the `plotly` CLI and Dash's cloud integration work out of the box. The `dash[cloud]` extra is kept for backward compatibility.
@@ -22,6 +24,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
2224
- [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`).
2325

2426
### Changed
27+
- [#4026](https://github.com/plotly/dash/pull/4026) Log a warning, once per process, when a streaming request is refused because its stream token failed verification, which on multi-worker deployments usually means the workers do not share a signing secret.
2528
- [#3987](https://github.com/plotly/dash/pull/3987) Forward FastAPI reload scope options (`reload_dirs`, `reload_excludes`, and `reload_includes`) to Uvicorn when reloading.
2629
- [#3986](https://github.com/plotly/dash/pull/3986) Adjust `_run_before_hooks` in the `fastapi` backend to honor a response returned by a `before_request` function, matching the `flask` backend's behavior.
2730

‎dash/_callback.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44
import logging
55
import re
6+
import threading
67
import warnings
78
from functools import wraps
89
from typing import Callable, Optional, Any, List, Tuple, Union, Dict, TypeVar, cast
@@ -465,11 +466,14 @@ def get_request_end_id(secret: bytes):
465466
request; this verifies the signature and returns the underlying end_id so
466467
background handles can be checked against it.
467468
"""
469+
return _callback_signing.unsign(
470+
secret, _callback_signing.END_SCOPE, _request_end_token()
471+
)
472+
473+
474+
def _request_end_token():
468475
adapter = get_app().backend.request_adapter()
469-
if not adapter:
470-
return None
471-
token = adapter.args.get("endId")
472-
return _callback_signing.unsign(secret, _callback_signing.END_SCOPE, token)
476+
return adapter.args.get("endId") if adapter else None
473477

474478

475479
def get_stream_connection_id() -> "str | None":
@@ -483,9 +487,9 @@ def get_stream_connection_id() -> "str | None":
483487
forged token yields ``None``, and the backend refuses the request (403).
484488
485489
``end_id`` is signed with the server secret, so across worker processes every
486-
worker must resolve the same secret: set a ``secret_key`` on the server, or
487-
cross-worker stream requests will not verify. Single-process apps are fine
488-
with no configuration.
490+
worker must resolve the same secret: set a ``secret_key`` on the server or
491+
the ``DASH_SECRET_KEY`` environment variable, or cross-worker stream requests
492+
will not verify. Single-process apps are fine with no configuration.
489493
490494
The renderer also sends a ``downlinkId`` it picks fresh for each run of
491495
streams, giving every run its own topic (``<end_id>:<downlinkId>``). A run
@@ -494,6 +498,8 @@ def get_stream_connection_id() -> "str | None":
494498
"""
495499
end_id = get_request_end_id(_get_signing_secret())
496500
if end_id is None:
501+
if _request_end_token():
502+
_warn_unverified_stream_token()
497503
return None
498504
downlink_id = get_app().backend.request_adapter().args.get("downlinkId")
499505
if not downlink_id:
@@ -504,6 +510,24 @@ def get_stream_connection_id() -> "str | None":
504510

505511

506512
_DOWNLINK_ID_RE = re.compile(r"[A-Za-z0-9_-]{1,64}")
513+
_stream_token_warning_lock = threading.Lock()
514+
_stream_token_warned = False
515+
516+
517+
def _warn_unverified_stream_token():
518+
# Once per process: a secret mismatch fails every cross-worker request.
519+
global _stream_token_warned # pylint: disable=global-statement
520+
with _stream_token_warning_lock:
521+
if _stream_token_warned:
522+
return
523+
_stream_token_warned = True
524+
get_app().logger.warning(
525+
"A streaming request was refused (403): its stream token failed "
526+
"verification. On multi-worker or multi-pod deployments this usually "
527+
"means the workers do not share a signing secret (or the server "
528+
"restarted since the page loaded); set server.secret_key or the "
529+
"DASH_SECRET_KEY environment variable."
530+
)
507531

508532

509533
def _get_signing_secret() -> bytes:

‎dash/_configs.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ def load_dash_env_vars():
3535
"DASH_COMPRESS",
3636
"DASH_MCP_ENABLED",
3737
"DASH_MCP_PATH",
38+
"DASH_SECRET_KEY",
39+
"DASH_SHARED_STORAGE",
3840
"HOST",
3941
"PORT",
4042
)

‎dash/_shared_storage/_env.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Pick a shared-storage backend from ``DASH_SHARED_STORAGE``.
2+
3+
Lets a hosting platform switch backends without editing the app's ``Dash(...)``
4+
call. Only used when the app did not pass ``shared_storage=``.
5+
"""
6+
7+
import functools
8+
from typing import Any, Dict, Optional
9+
from urllib.parse import parse_qsl, unquote, urlencode, urlparse
10+
11+
from ..exceptions import InvalidConfig
12+
from .diskcache import DiskcacheSharedStorage, _require_diskcache
13+
from .local import LocalSharedStorage
14+
from .redis import RedisSharedStorage, _require_redis
15+
16+
ENV_VAR = "DASH_SHARED_STORAGE"
17+
18+
19+
def _invalid(reason: str) -> InvalidConfig:
20+
# The value can hold credentials, so it stays out of the message.
21+
return InvalidConfig(
22+
f"{ENV_VAR} is not valid: {reason}. Use 'local', 'none', "
23+
"'diskcache:///absolute/path', or a redis:// or rediss:// URL."
24+
)
25+
26+
27+
def _redis_from_url(raw: str) -> Any:
28+
parsed = urlparse(raw)
29+
query = parse_qsl(parsed.query, keep_blank_values=True)
30+
kwargs: Dict[str, Any] = {}
31+
rest = []
32+
for key, value in query:
33+
if key == "key_prefix":
34+
if not value:
35+
raise _invalid("key_prefix cannot be empty")
36+
kwargs["key_prefix"] = value
37+
else:
38+
rest.append((key, value))
39+
url = parsed._replace(query=urlencode(rest)).geturl()
40+
return functools.partial(RedisSharedStorage, url=url, **kwargs)
41+
42+
43+
def storage_from_env(value: Optional[str]) -> Any:
44+
"""Turn a ``DASH_SHARED_STORAGE`` value into a ``shared_storage`` argument.
45+
46+
Returns ``None`` (disabled), or a zero-argument callable that builds the
47+
backend. Nothing is built or connected here, so startup stays lazy; missing
48+
optional dependencies still fail now, with the backend's own error.
49+
"""
50+
raw = (value or "").strip()
51+
lowered = raw.lower()
52+
if lowered in ("", "local"):
53+
return LocalSharedStorage
54+
if lowered == "none":
55+
return None
56+
57+
try:
58+
scheme = urlparse(raw).scheme.lower()
59+
except ValueError:
60+
raise _invalid("malformed URL") from None
61+
if scheme in ("redis", "rediss"):
62+
_require_redis()
63+
return _redis_from_url(raw)
64+
if scheme == "diskcache":
65+
parsed = urlparse(raw)
66+
if parsed.netloc or not parsed.path.startswith("/"):
67+
raise _invalid("diskcache needs an absolute path (three slashes)")
68+
_require_diskcache()
69+
return functools.partial(DiskcacheSharedStorage, directory=unquote(parsed.path))
70+
if scheme == "cluster":
71+
raise InvalidConfig(
72+
f"{ENV_VAR}: the cluster:// backend is not supported in this "
73+
"version of Dash."
74+
)
75+
raise _invalid("unknown backend")

‎dash/dash.py‎

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@
4848
)
4949
from .backends import get_backend
5050
from .version import __version__
51-
from ._configs import get_combined_config, pathname_configs, pages_folder_config
51+
from ._configs import (
52+
get_combined_config,
53+
load_dash_env_vars,
54+
pathname_configs,
55+
pages_folder_config,
56+
)
5257
from ._utils import (
5358
AttributeDict,
5459
format_tag,
@@ -76,11 +81,8 @@
7681
from . import backends
7782

7883
from ._get_app import with_app_context, with_app_context_factory
79-
from ._shared_storage import (
80-
BaseSharedStorage,
81-
LocalSharedStorage,
82-
SharedStorageError,
83-
)
84+
from ._shared_storage import BaseSharedStorage, SharedStorageError
85+
from ._shared_storage._env import storage_from_env
8486
from ._grouping import map_grouping, grouping_len, update_args_group
8587
from ._obsolete import ObsoleteChecker
8688
from ._callback_context import callback_context
@@ -154,6 +156,9 @@
154156
_ID_DUMMY = "_pages_dummy"
155157

156158
_UNINITIALIZED = object() # Sentinel for tracking init_app state
159+
# Default for ``shared_storage`` so "not passed" (DASH_SHARED_STORAGE may pick
160+
# the backend) can be told apart from an explicit argument.
161+
_SHARED_STORAGE_DEFAULT: Any = object()
157162

158163
DASH_VERSION_URL = "https://dash-version.plotly.com:8080/current_version"
159164

@@ -470,6 +475,20 @@ class Dash(ObsoleteChecker):
470475
takes a thread for milliseconds. ASGI backends (Quart, FastAPI) keep
471476
one open connection per browser instead and ignore this.
472477
:type stream_poll_interval: int
478+
479+
:param shared_storage: Backend for ``dash.ctx.shared_storage`` and the
480+
streaming-callback transport: a ``BaseSharedStorage`` subclass or
481+
instance, or ``None`` to disable. Default ``LocalSharedStorage`` (one
482+
machine or pod). When not passed, the ``DASH_SHARED_STORAGE``
483+
environment variable can choose it: ``local``, ``none``,
484+
``diskcache:///absolute/path``, or a ``redis://`` / ``rediss://`` URL.
485+
An explicit argument, including ``None``, always wins.
486+
487+
Stream requests are signed per page load, so with several workers or
488+
pods they all need the same signing secret: set ``server.secret_key``
489+
or the ``DASH_SECRET_KEY`` environment variable. ``DASH_SECRET_KEY`` is
490+
used for Dash's own signing only and does not set ``server.secret_key``.
491+
:type shared_storage: BaseSharedStorage subclass or instance, or None
473492
"""
474493

475494
_plotlyjs_url: str
@@ -535,7 +554,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
535554
stream_poll_interval: int = 100,
536555
shared_storage: Optional[
537556
Union[Type[BaseSharedStorage], BaseSharedStorage]
538-
] = LocalSharedStorage,
557+
] = _SHARED_STORAGE_DEFAULT,
539558
enable_mcp: Optional[bool] = None,
540559
mcp_path: Optional[str] = None,
541560
**obsolete,
@@ -724,6 +743,10 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
724743
# access so it costs nothing until used and never binds in a gunicorn
725744
# preload master or the Flask reloader parent -- only in the worker that
726745
# actually touches it.
746+
if shared_storage is _SHARED_STORAGE_DEFAULT:
747+
shared_storage = storage_from_env(
748+
load_dash_env_vars().get("DASH_SHARED_STORAGE")
749+
)
727750
self._shared_storage_arg = shared_storage
728751
self._shared_storage_instance: Optional[BaseSharedStorage] = None
729752
self._shared_storage_lock = threading.Lock()
@@ -753,9 +776,8 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
753776
self._pages_lock = threading.Lock()
754777
self._pages_async_lock: Optional[asyncio.Lock] = None
755778

756-
# Secret used to sign background-callback handles (see _callback_signing).
757-
# Prefer the Flask/Quart secret_key (shared across workers when the
758-
# operator sets one); otherwise fall back to a per-process random secret.
779+
# Fallback signing secret, used when neither server.secret_key nor
780+
# DASH_SECRET_KEY is set. See _get_signing_secret.
759781
self._generated_signing_secret: Optional[bytes] = None
760782

761783
if server:
@@ -1022,7 +1044,7 @@ def shared_storage(self) -> BaseSharedStorage:
10221044
with self._shared_storage_lock:
10231045
if self._shared_storage_instance is None:
10241046
storage = self._shared_storage_arg
1025-
if isinstance(storage, type):
1047+
if isinstance(storage, (type, functools.partial)):
10261048
storage = storage()
10271049
storage.start()
10281050
self._shared_storage_instance = storage
@@ -1071,21 +1093,29 @@ def serve_layout(self):
10711093
)
10721094

10731095
def _get_signing_secret(self) -> bytes:
1074-
"""Return the secret used to sign background-callback handles.
1096+
"""Return the secret used to sign page tokens (``end_id``), background
1097+
callback handles and stream connections.
10751098
10761099
Resolution order:
10771100
10781101
1. The server's ``secret_key`` if set (shared across workers when the
10791102
operator configures one, e.g. for Flask-Login).
1080-
2. Otherwise a random secret persisted in the background-callback result
1103+
2. ``DASH_SECRET_KEY`` from the environment, so a hosting platform can
1104+
give every worker and pod the same key without editing the app. Used
1105+
for Dash's own signing only, never assigned to ``server.secret_key``
1106+
(that would change Flask session behavior).
1107+
3. Otherwise a random secret persisted in the background-callback result
10811108
store, so every worker reads back the same value. This is exactly as
10821109
shared as the callback results themselves, so it works cross-worker
10831110
whenever the deployment is set up for multi-worker background
10841111
callbacks (an explicitly shared cache / broker).
1085-
3. Finally, if no background manager is available, a per-process random
1086-
secret (there are no background handles to verify in that case).
1112+
4. Finally, if no background manager is available, a per-process random
1113+
secret. Stream tokens then only verify on the worker that issued
1114+
them, so multi-worker streaming apps need 1 or 2.
10871115
"""
1088-
key = getattr(self.server, "secret_key", None)
1116+
key = getattr(self.server, "secret_key", None) or load_dash_env_vars().get(
1117+
"DASH_SECRET_KEY"
1118+
)
10891119
if key:
10901120
return key.encode("utf-8") if isinstance(key, str) else key
10911121
if self._generated_signing_secret is None:

0 commit comments

Comments
 (0)