Skip to content

Commit d282d93

Browse files
authored
Merge pull request #3902 from plotly/fix/callback-args-hardening
callback args hardening.
2 parents 27bddaa + f651bd5 commit d282d93

15 files changed

Lines changed: 661 additions & 32 deletions

‎CHANGELOG.md‎

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

77
## Fixed
8+
- [#3902](https://github.com/plotly/dash/pull/3902) Fix background callbacks trusting the client-supplied `job`/`oldJob`/`cancelJob` and `cacheKey` query parameters verbatim, which let an unauthenticated client terminate an arbitrary process (with `DiskcacheManager`, by sending its PID) or read and delete arbitrary result-cache entries. The `cacheKey`/`job` handles are now HMAC-signed by the server and bound to a per-page-load token, so forged or replayed values are rejected. Handles stay opaque to the renderer, so no app or callback code changes are required.
89
- [3883](https://github.com/plotly/dash/pull/3883) Fix callbacks being registered twice when running the app file as a script with a server that loads it by import string, e.g. `uvicorn.run("app:server", reload=True)` with `backend="fastapi"`. The spawned worker re-executes the main module as `__mp_main__` and the import string then executed the same file a second time, duplicating every callback in `_dash-dependencies` and triggering `Duplicate callback outputs` errors in the renderer. `Dash()` now pre-registers the running main module in `sys.modules` under its canonical import name so the second import reuses it instead of re-executing the file. Fixes [#3818](https://github.com/plotly/dash/issues/3818).
910
- [3885](https://github.com/plotly/dash/pull/3885) Fix Flask-WTF `CSRFProtect` (and Quart-WTF) exemptions breaking after the backend refactor. The callback dispatch view is now exposed with the fully-qualified name `dash.dash.dispatch` again, so `csrf._exempt_views.add("dash.dash.dispatch")` works as it did in Dash 4.1.0. Fixes [#3827](https://github.com/plotly/dash/issues/3827).
1011
- [#3882](https://github.com/plotly/dash/pull/3882) Fix `dcc.Graph` user interactions (pan, zoom, edited shapes & annotations, ...) being reverted by a subsequent `Patch` update, by syncing all relayout changes back to the `figure` prop instead of only shapes. Fixes [#3810](https://github.com/plotly/dash/issues/3810).

‎dash/_callback.py‎

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
ImportedInsideCallbackError,
2525
)
2626
from ._get_app import get_app
27+
from . import _callback_signing
2728
from ._grouping import (
2829
flatten_grouping,
2930
make_grouping_by_index,
@@ -387,6 +388,24 @@ def _initialize_context(args, kwargs, inputs_state_indices, has_output, insert_o
387388
)
388389

389390

391+
def get_request_end_id(secret: bytes):
392+
"""Return the verified end_id for the current request, or ``None``.
393+
394+
The renderer echoes the server-signed ``endId`` token on every callback
395+
request; this verifies the signature and returns the underlying end_id so
396+
background handles can be checked against it.
397+
"""
398+
adapter = get_app().backend.request_adapter()
399+
if not adapter:
400+
return None
401+
token = adapter.args.get("endId")
402+
return _callback_signing.unsign(secret, _callback_signing.END_SCOPE, token)
403+
404+
405+
def _get_signing_secret() -> bytes:
406+
return get_app()._get_signing_secret() # pylint: disable=protected-access
407+
408+
390409
def _get_callback_manager(
391410
kwargs: dict, background: dict
392411
) -> BaseBackgroundCallbackManager:
@@ -409,8 +428,14 @@ def _get_callback_manager(
409428
old_job = adapter.args.getlist("oldJob") if hasattr(adapter.args, "getlist") else []
410429

411430
if old_job:
412-
for job in old_job:
413-
callback_manager.terminate_job(job)
431+
secret = _get_signing_secret()
432+
scope = _callback_signing.job_scope(get_request_end_id(secret))
433+
for signed_job in old_job:
434+
# Only terminate jobs whose handle we actually signed for this page
435+
# load; ignore forged or replayed pids.
436+
job = _callback_signing.unsign(secret, scope, signed_job)
437+
if job is not None:
438+
callback_manager.terminate_job(job)
414439

415440
return callback_manager
416441

@@ -450,9 +475,18 @@ def _setup_background_callback(
450475
ctx_value,
451476
)
452477

478+
# Sign the handles before handing them to the browser so they cannot be
479+
# forged (arbitrary pid kill / arbitrary cache read) or replayed from
480+
# another page load. The renderer treats them as opaque strings.
481+
secret = _get_signing_secret()
482+
end_id = get_request_end_id(secret)
453483
data = {
454-
"cacheKey": cache_key,
455-
"job": job,
484+
"cacheKey": _callback_signing.sign(
485+
secret, _callback_signing.cache_scope(end_id), cache_key
486+
),
487+
"job": _callback_signing.sign(
488+
secret, _callback_signing.job_scope(end_id), str(job)
489+
),
456490
}
457491

458492
cancel = background.get("cancel")
@@ -467,15 +501,38 @@ def _setup_background_callback(
467501
return to_json(data)
468502

469503

504+
def _read_request_handles():
505+
"""Read and verify the signed cacheKey/job handles from the current request.
506+
507+
Returns ``(cache_key, job_id)`` as the raw (unsigned) values, or ``None`` for
508+
any handle that is missing or whose signature does not verify against this
509+
page load's end_id. This is what prevents a client from supplying an
510+
arbitrary cache key (read/delete) or an arbitrary pid (kill).
511+
"""
512+
adapter = get_app().backend.request_adapter()
513+
if not adapter:
514+
return None, None
515+
secret = _get_signing_secret()
516+
end_id = get_request_end_id(secret)
517+
cache_key = _callback_signing.unsign(
518+
secret,
519+
_callback_signing.cache_scope(end_id),
520+
adapter.args.get("cacheKey"),
521+
)
522+
job_id = _callback_signing.unsign(
523+
secret,
524+
_callback_signing.job_scope(end_id),
525+
adapter.args.get("job"),
526+
)
527+
return cache_key, job_id
528+
529+
470530
def _progress_background_callback(
471531
response, callback_manager, background, cache_key=None
472532
):
473533
progress_outputs = background.get("progress")
474-
if cache_key is None:
475-
adapter = get_app().backend.request_adapter()
476-
cache_key = adapter.args.get("cacheKey")
477534

478-
if progress_outputs:
535+
if progress_outputs and cache_key is not None:
479536
# Get the progress before the result as it would be erased after the results.
480537
progress = callback_manager.get_progress(cache_key)
481538
if progress:
@@ -498,15 +555,19 @@ def _update_background_callback(
498555
callback_manager = _get_callback_manager(kwargs, background)
499556

500557
if cache_key is None or job_id is None:
501-
adapter = get_app().backend.request_adapter()
502-
cache_key = cache_key or (adapter.args.get("cacheKey") if adapter else None)
503-
job_id = job_id or (adapter.args.get("job") if adapter else None)
558+
req_cache_key, req_job_id = _read_request_handles()
559+
cache_key = cache_key or req_cache_key
560+
job_id = job_id or req_job_id
504561

505562
_progress_background_callback(
506563
response, callback_manager, background, cache_key=cache_key
507564
)
508565

509-
output_value = callback_manager.get_result(cache_key, job_id)
566+
if cache_key is None:
567+
# No valid handle for this page load -> read nothing, delete nothing.
568+
output_value = callback_manager.UNDEFINED
569+
else:
570+
output_value = callback_manager.get_result(cache_key, job_id)
510571

511572
return _handle_rest_background_callback(
512573
output_value,
@@ -531,12 +592,8 @@ def _handle_rest_background_callback(
531592
cache_key=None,
532593
job_id=None,
533594
):
534-
if cache_key is None or job_id is None:
535-
adapter = get_app().backend.request_adapter()
536-
cache_key = cache_key or (adapter.args.get("cacheKey") if adapter else None)
537-
job_id = job_id or (adapter.args.get("job") if adapter else None)
538595
# Must get job_running after get_result since get_results terminates it.
539-
job_running = callback_manager.job_running(job_id)
596+
job_running = callback_manager.job_running(job_id) if job_id is not None else False
540597
if not job_running and output_value is callback_manager.UNDEFINED:
541598
# Job canceled -> no output to close the loop.
542599
output_value = NoUpdate()

‎dash/_callback_signing.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Signing helpers for background-callback handles.
2+
3+
Background callbacks round-trip two values through the browser: the ``cacheKey``
4+
(the diskcache/celery result key) and the ``job`` (the OS PID or Celery task id).
5+
Historically these were sent to the client verbatim and trusted verbatim when
6+
they came back, which let any HTTP client ask the server to read/delete an
7+
arbitrary cache entry or to ``SIGKILL`` an arbitrary process id.
8+
9+
To close that, the server issues a per-page-load ``end_id`` token (signed so the
10+
server can recognise the ones it minted), and every ``cacheKey``/``job`` handle
11+
is HMAC-signed with a server-held secret and bound to that ``end_id``. A forged
12+
PID or arbitrary cache key never carries a valid signature, and a handle minted
13+
for one page load cannot be replayed from another. The client treats all of
14+
these as opaque strings, so nothing about the handshake is visible to or
15+
forgeable by the browser.
16+
17+
(This ``end_id`` is unrelated to the client-generated ``rendererId`` used for
18+
SharedWorker routing in ``dash-renderer/src/utils/rendererId.ts``.)
19+
"""
20+
21+
import hmac
22+
from hashlib import sha256
23+
from typing import Optional
24+
25+
# Separates the payload from its signature in a signed token. Chosen because it
26+
# never appears in the values we sign (hex digests, integers, and gen_salt
27+
# output are all limited to ``[A-Za-z0-9]``).
28+
_SEP = "~"
29+
30+
31+
def _mac(secret: bytes, scope: str, value: str) -> str:
32+
msg = f"{scope}\x00{value}".encode("utf-8")
33+
return hmac.new(secret, msg, sha256).hexdigest()
34+
35+
36+
def sign(secret: bytes, scope: str, value: str) -> str:
37+
"""Return ``value`` with an HMAC signature appended, bound to ``scope``."""
38+
return f"{value}{_SEP}{_mac(secret, scope, value)}"
39+
40+
41+
def unsign(secret: bytes, scope: str, signed: Optional[str]) -> Optional[str]:
42+
"""Verify a signed token and return the original value, or ``None``.
43+
44+
Returns ``None`` when the token is missing, malformed, or the signature does
45+
not match ``scope`` under ``secret``. Uses a constant-time comparison so the
46+
check does not leak the expected signature.
47+
"""
48+
if not signed or _SEP not in signed:
49+
return None
50+
51+
value, _, mac = signed.rpartition(_SEP)
52+
expected = _mac(secret, scope, value)
53+
if hmac.compare_digest(mac, expected):
54+
return value
55+
return None
56+
57+
58+
# Scope strings. The end_id is folded into the job/cache scopes so a handle is
59+
# only valid for the page load it was issued to.
60+
END_SCOPE = "end"
61+
62+
63+
def job_scope(end_id: Optional[str]) -> str:
64+
return f"job:{end_id or ''}"
65+
66+
67+
def cache_scope(end_id: Optional[str]) -> str:
68+
return f"cacheKey:{end_id or ''}"

‎dash/background_callback/managers/__init__.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ def get_result(self, key, job):
5555
def get_updated_props(self, key):
5656
raise NotImplementedError
5757

58+
# Key under which the shared background-callback signing secret is stored in
59+
# the result store, so all workers agree on one secret without a configured
60+
# server secret_key. See Dash._get_signing_secret.
61+
SIGNING_SECRET_KEY = "__dash_background_signing_secret__"
62+
63+
def get_or_create_signing_secret(self, generate):
64+
"""Return a signing secret shared across workers via the result store.
65+
66+
``generate`` is a zero-argument callable returning fresh ``bytes``. The
67+
first worker to reach an empty store wins; every other worker reads back
68+
the stored value, so all workers converge on a single secret.
69+
"""
70+
raise NotImplementedError
71+
5872
def build_cache_key(self, fn, args, cache_args_to_ignore, triggered):
5973
try:
6074
fn_source = inspect.getsource(fn)

‎dash/background_callback/managers/celery_manager.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ def get_task(self, job):
9292
def clear_cache_entry(self, key):
9393
self.handle.backend.delete(key)
9494

95+
def get_or_create_signing_secret(self, generate):
96+
backend = self.handle.backend
97+
existing = backend.get(self.SIGNING_SECRET_KEY)
98+
if existing is not None:
99+
return existing
100+
secret = generate()
101+
backend.set(self.SIGNING_SECRET_KEY, secret)
102+
# Re-read so workers that raced on the initial set converge on one value.
103+
return backend.get(self.SIGNING_SECRET_KEY) or secret
104+
95105
def call_job_fn(self, key, job_fn, args, context):
96106
task = job_fn.delay(key, self._make_progress_key(key), args, context)
97107
return task.task_id

‎dash/background_callback/managers/diskcache_manager.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ def make_job_fn(self, fn, progress, key=None):
118118
def clear_cache_entry(self, key):
119119
self.handle.delete(key)
120120

121+
def get_or_create_signing_secret(self, generate):
122+
# ``add`` only sets the value if the key is absent (atomic in diskcache),
123+
# so the first worker wins and the rest read back the stored secret.
124+
self.handle.add(self.SIGNING_SECRET_KEY, generate())
125+
return self.handle.get(self.SIGNING_SECRET_KEY)
126+
121127
# noinspection PyUnresolvedReferences
122128
def call_job_fn(self, key, job_fn, args, context):
123129
"""

‎dash/dash-renderer/src/actions/callbacks.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,11 @@ function handleServerside(
499499
}
500500
url = `${url}${delim}${name}=${value}`;
501501
};
502+
// Echo the server-issued token so the server can bind/verify the
503+
// background-callback handles (cacheKey/job/oldJob/cancelJob) it signs.
504+
if (config.end_id) {
505+
addArg('endId', config.end_id);
506+
}
502507
if (cacheKey || job) {
503508
if (cacheKey) addArg('cacheKey', cacheKey);
504509
if (job) addArg('job', job);

‎dash/dash-renderer/src/config.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export type DashConfig = {
3131
};
3232
csrf_token_name?: string;
3333
csrf_header_name?: string;
34+
// Server-issued, server-signed token for this page load. Echoed on every
35+
// callback request so the server can bind/verify background-callback
36+
// handles (cacheKey/job) to this page load. Treated as an opaque string.
37+
// (Unrelated to the client-side rendererId used for SharedWorker routing.)
38+
end_id?: string;
3439
};
3540

3641
export default function getConfigFromDOM(): DashConfig {

0 commit comments

Comments
 (0)