From 08cc2b1407a8308ea58215bb8f7e536fa57722bc Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 17 Jul 2026 08:35:49 -0400 Subject: [PATCH 1/5] callback args hardening. --- dash/_callback.py | 91 ++++++-- dash/_callback_signing.py | 68 ++++++ dash/background_callback/managers/__init__.py | 14 ++ .../managers/celery_manager.py | 10 + .../managers/diskcache_manager.py | 6 + dash/dash-renderer/src/actions/callbacks.ts | 5 + dash/dash-renderer/src/config.ts | 5 + dash/dash.py | 87 ++++++- dash/mcp/tasks/tasks.py | 26 ++- .../mcp/test_mcp_background_tasks.py | 106 ++++++++- .../unit/test_background_callback_signing.py | 219 ++++++++++++++++++ 11 files changed, 612 insertions(+), 25 deletions(-) create mode 100644 dash/_callback_signing.py create mode 100644 tests/unit/test_background_callback_signing.py diff --git a/dash/_callback.py b/dash/_callback.py index 81a5345830..61908942ef 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -24,6 +24,7 @@ ImportedInsideCallbackError, ) from ._get_app import get_app +from . import _callback_signing from ._grouping import ( flatten_grouping, make_grouping_by_index, @@ -387,6 +388,24 @@ def _initialize_context(args, kwargs, inputs_state_indices, has_output, insert_o ) +def get_request_end_id(secret: bytes): + """Return the verified end_id for the current request, or ``None``. + + The renderer echoes the server-signed ``endId`` token on every callback + request; this verifies the signature and returns the underlying end_id so + background handles can be checked against it. + """ + adapter = get_app().backend.request_adapter() + if not adapter: + return None + token = adapter.args.get("endId") + return _callback_signing.unsign(secret, _callback_signing.END_SCOPE, token) + + +def _get_signing_secret() -> bytes: + return get_app()._get_signing_secret() # pylint: disable=protected-access + + def _get_callback_manager( kwargs: dict, background: dict ) -> BaseBackgroundCallbackManager: @@ -409,8 +428,14 @@ def _get_callback_manager( old_job = adapter.args.getlist("oldJob") if hasattr(adapter.args, "getlist") else [] if old_job: - for job in old_job: - callback_manager.terminate_job(job) + secret = _get_signing_secret() + scope = _callback_signing.job_scope(get_request_end_id(secret)) + for signed_job in old_job: + # Only terminate jobs whose handle we actually signed for this page + # load; ignore forged or replayed pids. + job = _callback_signing.unsign(secret, scope, signed_job) + if job is not None: + callback_manager.terminate_job(job) return callback_manager @@ -450,9 +475,18 @@ def _setup_background_callback( ctx_value, ) + # Sign the handles before handing them to the browser so they cannot be + # forged (arbitrary pid kill / arbitrary cache read) or replayed from + # another page load. The renderer treats them as opaque strings. + secret = _get_signing_secret() + end_id = get_request_end_id(secret) data = { - "cacheKey": cache_key, - "job": job, + "cacheKey": _callback_signing.sign( + secret, _callback_signing.cache_scope(end_id), cache_key + ), + "job": _callback_signing.sign( + secret, _callback_signing.job_scope(end_id), str(job) + ), } cancel = background.get("cancel") @@ -467,15 +501,38 @@ def _setup_background_callback( return to_json(data) +def _read_request_handles(): + """Read and verify the signed cacheKey/job handles from the current request. + + Returns ``(cache_key, job_id)`` as the raw (unsigned) values, or ``None`` for + any handle that is missing or whose signature does not verify against this + page load's end_id. This is what prevents a client from supplying an + arbitrary cache key (read/delete) or an arbitrary pid (kill). + """ + adapter = get_app().backend.request_adapter() + if not adapter: + return None, None + secret = _get_signing_secret() + end_id = get_request_end_id(secret) + cache_key = _callback_signing.unsign( + secret, + _callback_signing.cache_scope(end_id), + adapter.args.get("cacheKey"), + ) + job_id = _callback_signing.unsign( + secret, + _callback_signing.job_scope(end_id), + adapter.args.get("job"), + ) + return cache_key, job_id + + def _progress_background_callback( response, callback_manager, background, cache_key=None ): progress_outputs = background.get("progress") - if cache_key is None: - adapter = get_app().backend.request_adapter() - cache_key = adapter.args.get("cacheKey") - if progress_outputs: + if progress_outputs and cache_key is not None: # Get the progress before the result as it would be erased after the results. progress = callback_manager.get_progress(cache_key) if progress: @@ -498,15 +555,19 @@ def _update_background_callback( callback_manager = _get_callback_manager(kwargs, background) if cache_key is None or job_id is None: - adapter = get_app().backend.request_adapter() - cache_key = cache_key or (adapter.args.get("cacheKey") if adapter else None) - job_id = job_id or (adapter.args.get("job") if adapter else None) + req_cache_key, req_job_id = _read_request_handles() + cache_key = cache_key or req_cache_key + job_id = job_id or req_job_id _progress_background_callback( response, callback_manager, background, cache_key=cache_key ) - output_value = callback_manager.get_result(cache_key, job_id) + if cache_key is None: + # No valid handle for this page load -> read nothing, delete nothing. + output_value = callback_manager.UNDEFINED + else: + output_value = callback_manager.get_result(cache_key, job_id) return _handle_rest_background_callback( output_value, @@ -531,12 +592,8 @@ def _handle_rest_background_callback( cache_key=None, job_id=None, ): - if cache_key is None or job_id is None: - adapter = get_app().backend.request_adapter() - cache_key = cache_key or (adapter.args.get("cacheKey") if adapter else None) - job_id = job_id or (adapter.args.get("job") if adapter else None) # Must get job_running after get_result since get_results terminates it. - job_running = callback_manager.job_running(job_id) + job_running = callback_manager.job_running(job_id) if job_id is not None else False if not job_running and output_value is callback_manager.UNDEFINED: # Job canceled -> no output to close the loop. output_value = NoUpdate() diff --git a/dash/_callback_signing.py b/dash/_callback_signing.py new file mode 100644 index 0000000000..2472dc3ba0 --- /dev/null +++ b/dash/_callback_signing.py @@ -0,0 +1,68 @@ +"""Signing helpers for background-callback handles. + +Background callbacks round-trip two values through the browser: the ``cacheKey`` +(the diskcache/celery result key) and the ``job`` (the OS PID or Celery task id). +Historically these were sent to the client verbatim and trusted verbatim when +they came back, which let any HTTP client ask the server to read/delete an +arbitrary cache entry or to ``SIGKILL`` an arbitrary process id. + +To close that, the server issues a per-page-load ``end_id`` token (signed so the +server can recognise the ones it minted), and every ``cacheKey``/``job`` handle +is HMAC-signed with a server-held secret and bound to that ``end_id``. A forged +PID or arbitrary cache key never carries a valid signature, and a handle minted +for one page load cannot be replayed from another. The client treats all of +these as opaque strings, so nothing about the handshake is visible to or +forgeable by the browser. + +(This ``end_id`` is unrelated to the client-generated ``rendererId`` used for +SharedWorker routing in ``dash-renderer/src/utils/rendererId.ts``.) +""" + +import hmac +from hashlib import sha256 +from typing import Optional + +# Separates the payload from its signature in a signed token. Chosen because it +# never appears in the values we sign (hex digests, integers, and gen_salt +# output are all limited to ``[A-Za-z0-9]``). +_SEP = "~" + + +def _mac(secret: bytes, scope: str, value: str) -> str: + msg = f"{scope}\x00{value}".encode("utf-8") + return hmac.new(secret, msg, sha256).hexdigest() + + +def sign(secret: bytes, scope: str, value: str) -> str: + """Return ``value`` with an HMAC signature appended, bound to ``scope``.""" + return f"{value}{_SEP}{_mac(secret, scope, value)}" + + +def unsign(secret: bytes, scope: str, signed: Optional[str]) -> Optional[str]: + """Verify a signed token and return the original value, or ``None``. + + Returns ``None`` when the token is missing, malformed, or the signature does + not match ``scope`` under ``secret``. Uses a constant-time comparison so the + check does not leak the expected signature. + """ + if not signed or _SEP not in signed: + return None + + value, _, mac = signed.rpartition(_SEP) + expected = _mac(secret, scope, value) + if hmac.compare_digest(mac, expected): + return value + return None + + +# Scope strings. The end_id is folded into the job/cache scopes so a handle is +# only valid for the page load it was issued to. +END_SCOPE = "end" + + +def job_scope(end_id: Optional[str]) -> str: + return f"job:{end_id or ''}" + + +def cache_scope(end_id: Optional[str]) -> str: + return f"cacheKey:{end_id or ''}" diff --git a/dash/background_callback/managers/__init__.py b/dash/background_callback/managers/__init__.py index 4145f27f63..f7faa1fbb5 100644 --- a/dash/background_callback/managers/__init__.py +++ b/dash/background_callback/managers/__init__.py @@ -55,6 +55,20 @@ def get_result(self, key, job): def get_updated_props(self, key): raise NotImplementedError + # Key under which the shared background-callback signing secret is stored in + # the result store, so all workers agree on one secret without a configured + # server secret_key. See Dash._get_signing_secret. + SIGNING_SECRET_KEY = "__dash_background_signing_secret__" + + def get_or_create_signing_secret(self, generate): + """Return a signing secret shared across workers via the result store. + + ``generate`` is a zero-argument callable returning fresh ``bytes``. The + first worker to reach an empty store wins; every other worker reads back + the stored value, so all workers converge on a single secret. + """ + raise NotImplementedError + def build_cache_key(self, fn, args, cache_args_to_ignore, triggered): try: fn_source = inspect.getsource(fn) diff --git a/dash/background_callback/managers/celery_manager.py b/dash/background_callback/managers/celery_manager.py index 3b416d488b..eb2864caa0 100644 --- a/dash/background_callback/managers/celery_manager.py +++ b/dash/background_callback/managers/celery_manager.py @@ -92,6 +92,16 @@ def get_task(self, job): def clear_cache_entry(self, key): self.handle.backend.delete(key) + def get_or_create_signing_secret(self, generate): + backend = self.handle.backend + existing = backend.get(self.SIGNING_SECRET_KEY) + if existing is not None: + return existing + secret = generate() + backend.set(self.SIGNING_SECRET_KEY, secret) + # Re-read so workers that raced on the initial set converge on one value. + return backend.get(self.SIGNING_SECRET_KEY) or secret + def call_job_fn(self, key, job_fn, args, context): task = job_fn.delay(key, self._make_progress_key(key), args, context) return task.task_id diff --git a/dash/background_callback/managers/diskcache_manager.py b/dash/background_callback/managers/diskcache_manager.py index 38a27c25c2..b7326a0788 100644 --- a/dash/background_callback/managers/diskcache_manager.py +++ b/dash/background_callback/managers/diskcache_manager.py @@ -118,6 +118,12 @@ def make_job_fn(self, fn, progress, key=None): def clear_cache_entry(self, key): self.handle.delete(key) + def get_or_create_signing_secret(self, generate): + # ``add`` only sets the value if the key is absent (atomic in diskcache), + # so the first worker wins and the rest read back the stored secret. + self.handle.add(self.SIGNING_SECRET_KEY, generate()) + return self.handle.get(self.SIGNING_SECRET_KEY) + # noinspection PyUnresolvedReferences def call_job_fn(self, key, job_fn, args, context): """ diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index e6604a2337..6dec01996c 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -499,6 +499,11 @@ function handleServerside( } url = `${url}${delim}${name}=${value}`; }; + // Echo the server-issued token so the server can bind/verify the + // background-callback handles (cacheKey/job/oldJob/cancelJob) it signs. + if (config.end_id) { + addArg('endId', config.end_id); + } if (cacheKey || job) { if (cacheKey) addArg('cacheKey', cacheKey); if (job) addArg('job', job); diff --git a/dash/dash-renderer/src/config.ts b/dash/dash-renderer/src/config.ts index 86d804bb57..42473a4a55 100644 --- a/dash/dash-renderer/src/config.ts +++ b/dash/dash-renderer/src/config.ts @@ -31,6 +31,11 @@ export type DashConfig = { }; csrf_token_name?: string; csrf_header_name?: string; + // Server-issued, server-signed token for this page load. Echoed on every + // callback request so the server can bind/verify background-callback + // handles (cacheKey/job) to this page load. Treated as an opaque string. + // (Unrelated to the client-side rendererId used for SharedWorker routing.) + end_id?: string; }; export default function getConfigFromDOM(): DashConfig { diff --git a/dash/dash.py b/dash/dash.py index d114197fd2..fd6a8d017d 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -56,6 +56,7 @@ alias_main_module, ) from . import _callback +from . import _callback_signing from . import _get_paths from . import _dash_renderer from . import _validate @@ -684,6 +685,11 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches # tracks internally if a function already handled at least one request. self._got_first_request = {"pages": False, "setup_server": False} + # Secret used to sign background-callback handles (see _callback_signing). + # Prefer the Flask/Quart secret_key (shared across workers when the + # operator sets one); otherwise fall back to a per-process random secret. + self._generated_signing_secret: Optional[bytes] = None + if server: self.init_app() @@ -965,6 +971,69 @@ def serve_layout(self): mimetype="application/json", ) + def _get_signing_secret(self) -> bytes: + """Return the secret used to sign background-callback handles. + + Resolution order: + + 1. The server's ``secret_key`` if set (shared across workers when the + operator configures one, e.g. for Flask-Login). + 2. Otherwise a random secret persisted in the background-callback result + store, so every worker reads back the same value. This is exactly as + shared as the callback results themselves, so it works cross-worker + whenever the deployment is set up for multi-worker background + callbacks (an explicitly shared cache / broker). + 3. Finally, if no background manager is available, a per-process random + secret (there are no background handles to verify in that case). + """ + key = getattr(self.server, "secret_key", None) + if key: + return key.encode("utf-8") if isinstance(key, str) else key + if self._generated_signing_secret is None: + self._generated_signing_secret = self._resolve_fallback_signing_secret() + return self._generated_signing_secret + + def _background_managers(self): + """The background-callback managers this app uses, deterministically + ordered and de-duplicated by identity.""" + managers = [] + seen = set() + for candidate in [self._background_manager] + [ + cb.get("manager") for cb in self.callback_map.values() + ]: + if candidate is not None and id(candidate) not in seen: + seen.add(id(candidate)) + managers.append(candidate) + return managers + + def _resolve_fallback_signing_secret(self) -> bytes: + def _generate() -> bytes: + return gen_salt(64).encode("utf-8") + + def _coerce(value) -> bytes: + if isinstance(value, bytes): + return value + if isinstance(value, str): + return value.encode("utf-8") + return bytes(value) + + # Persist the secret in the (shared) result store this app's manager + # uses so all workers read back the same value. Every worker runs the + # same code, so they resolve the same manager and the same store. + for manager in self._background_managers(): + try: + secret = manager.get_or_create_signing_secret(_generate) + except NotImplementedError: + continue + except Exception: # pylint: disable=broad-except + # A misbehaving/unreachable store must not break app startup; + # try the next manager, then fall back to a per-process secret. + continue + if secret: + return _coerce(secret) + + return _generate() + def _config(self): # pieces of config needed by the front end config = { @@ -987,6 +1056,17 @@ def _config(self): "csrf_token_name": self.config.csrf_token_name, "csrf_header_name": self.config.csrf_header_name, } + + # Server-issued, server-signed token for this page load. The renderer + # echoes it on every callback request; the server binds background + # callback handles (cacheKey/job) to it so they cannot be forged or + # replayed from another page load. See dash/_callback_signing.py. + end_id = gen_salt(24) + config["end_id"] = _callback_signing.sign( + self._get_signing_secret(), + _callback_signing.END_SCOPE, + end_id, + ) if self._plotly_cloud is None: if os.getenv("DASH_ENTERPRISE_ENV") == "WORKSPACE": # Disable the placeholder button on workspace. @@ -1697,8 +1777,13 @@ def cancel_call(*_): job_ids = callback_context.args.getlist("cancelJob") executor = _callback.context_value.get().background_callback_manager if job_ids: + secret = self._get_signing_secret() + end_id = _callback.get_request_end_id(secret) + scope = _callback_signing.job_scope(end_id) for job_id in job_ids: - executor.terminate_job(job_id) + job = _callback_signing.unsign(secret, scope, job_id) + if job is not None: + executor.terminate_job(job) return no_update def _add_assets_resource(self, url_path, file_path): diff --git a/dash/mcp/tasks/tasks.py b/dash/mcp/tasks/tasks.py index a8e6217b0c..5f0ac3faf2 100644 --- a/dash/mcp/tasks/tasks.py +++ b/dash/mcp/tasks/tasks.py @@ -8,6 +8,7 @@ from dash.mcp.types import CancelTaskResult, CreateTaskResult, GetTaskResult, Task from dash import get_app +from dash import _callback_signing from dash._callback import _update_background_callback, _prepare_response from dash._utils import AttributeDict, split_callback_id from dash.development.base_component import ComponentRegistry @@ -17,13 +18,32 @@ def parse_task_id(task_id: str) -> tuple[str, str, str, datetime]: - """Parse a taskId into (tool_name, job_id, cache_key, created_at).""" + """Parse a taskId into (tool_name, job_id, cache_key, created_at). + + The ``job_id`` and ``cache_key`` embedded in a taskId are the *signed* + handles issued by the background callback dispatch (see + ``dash._callback_signing``). They are verified and unwrapped here, so a + forged or tampered taskId is rejected before its values ever reach the + callback manager (which would otherwise ``kill`` an arbitrary pid or + read/delete an arbitrary cache entry). MCP requests carry no end_id, so the + handles are signed and verified with a ``None`` end_id scope. + """ try: - tool_name, job_id, rest = task_id.split(":", 2) - cache_key, created_epoch = rest.rsplit(":", 1) + tool_name, signed_job, rest = task_id.split(":", 2) + signed_cache_key, created_epoch = rest.rsplit(":", 1) created_at = datetime.fromtimestamp(int(created_epoch), tz=timezone.utc) except (ValueError, TypeError) as exc: raise MCPError(f"Malformed taskId: {task_id!r}") from exc + + secret = get_app()._get_signing_secret() # pylint: disable=protected-access + job_id = _callback_signing.unsign( + secret, _callback_signing.job_scope(None), signed_job + ) + cache_key = _callback_signing.unsign( + secret, _callback_signing.cache_scope(None), signed_cache_key + ) + if job_id is None or cache_key is None: + raise MCPError(f"Invalid taskId: {task_id!r}") return tool_name, job_id, cache_key, created_at diff --git a/tests/integration/mcp/test_mcp_background_tasks.py b/tests/integration/mcp/test_mcp_background_tasks.py index 8f8b946754..3bbfcfebef 100644 --- a/tests/integration/mcp/test_mcp_background_tasks.py +++ b/tests/integration/mcp/test_mcp_background_tasks.py @@ -8,15 +8,37 @@ import json import re +import subprocess +import sys import time import diskcache -from dash import Dash, Input, Output, html +import psutil +from dash import Dash, Input, Output, html, _callback_signing from dash.background_callback.managers.diskcache_manager import DiskcacheManager MCP_PATH = "_mcp" +def _unwrap_handles(app, task_id): + """Return the raw (unsigned) ``(job_id, cache_key)`` from a signed taskId. + + The handles embedded in a taskId are HMAC-signed (see ``_callback_signing``); + tests that poke the manager directly must unwrap them first. MCP dispatch has + no end_id, so the ``None`` end_id scope is used. + """ + secret = app._get_signing_secret() + _tool, signed_job, rest = task_id.split(":", 2) + signed_cache, _epoch = rest.rsplit(":", 1) + job_id = _callback_signing.unsign( + secret, _callback_signing.job_scope(None), signed_job + ) + cache_key = _callback_signing.unsign( + secret, _callback_signing.cache_scope(None), signed_cache + ) + return job_id, cache_key + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -98,7 +120,7 @@ def test_mcpbg012_trigger_poll_and_retrieve(): assert poll_data["status"] == "working" # Wait for completion - job_id = task_id.split(":")[1] + job_id, _ = _unwrap_handles(app, task_id) manager = app.callback_map["output.children"]["manager"] deadline = time.time() + 5 while time.time() < deadline: @@ -148,7 +170,7 @@ def fast_cb(value): ) task_info = json.loads(json.loads(r.data)["result"]["content"][0]["text"]) task_id = task_info["taskId"] - job_id = task_id.split(":")[1] + job_id, _ = _unwrap_handles(app, task_id) deadline = time.time() + 3 while time.time() < deadline: @@ -310,7 +332,7 @@ def callback_b(value): assert r.status_code == 200 task_info = json.loads(json.loads(r.data)["result"]["content"][0]["text"]) task_id = task_info["taskId"] - cache_key = task_id.split(":")[2] + _, cache_key = _unwrap_handles(app, task_id) deadline = time.time() + 5 while time.time() < deadline: @@ -324,3 +346,79 @@ def callback_b(value): r = _post(client, "tasks/get", {"taskId": task_id}, request_id=2) assert r.status_code == 200 assert json.loads(r.data)["result"]["status"] == "completed" + + +# --------------------------------------------------------------------------- +# Security: taskId handles are signed and verified end-to-end +# --------------------------------------------------------------------------- + + +def test_mcpbg017_forged_cancel_does_not_kill_arbitrary_process(): + """A crafted taskId with an arbitrary pid must not reach terminate_job.""" + app = _make_background_app() + client = app.server.test_client() + + victim = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + time.sleep(0.2) + assert psutil.pid_exists(victim.pid) + + # Unsigned taskId an attacker would craft: ::: + forged = f"slow_callback:{victim.pid}:deadbeef:0" + r = _post(client, "tasks/cancel", {"taskId": forged}) + + # The malformed/forged handle is rejected as a JSON-RPC error, and the + # unrelated process is left untouched. + body = json.loads(r.data) + assert "error" in body or body.get("result", {}).get("status") != "cancelled" + time.sleep(0.3) + assert psutil.pid_exists(victim.pid) + assert psutil.Process(victim.pid).status() != psutil.STATUS_ZOMBIE + finally: + victim.kill() + + +def test_mcpbg018_forged_result_does_not_read_or_delete_cache(): + """A crafted taskId with an arbitrary cacheKey must not read/delete it.""" + app = _make_background_app() + manager = app.callback_map["output.children"]["manager"] + client = app.server.test_client() + + manager.handle.set("operator-secret-key", {"secret": "topsecret"}) + + forged = "slow_callback:1:operator-secret-key:0" + for method in ("tasks/result", "tasks/get"): + r = _post(client, method, {"taskId": forged}) + assert "topsecret" not in r.get_data(as_text=True) + + # The unrelated entry is neither disclosed nor deleted. + assert manager.handle.get("operator-secret-key") == {"secret": "topsecret"} + + +def test_mcpbg019_legitimate_cancel_terminates_the_job(): + """The real signed taskId still cancels its own background job.""" + app = _make_background_app() + client = app.server.test_client() + + r = _post( + client, + "tools/call", + {"name": "slow_callback", "arguments": {"value": "hello"}}, + ) + task_info = json.loads(json.loads(r.data)["result"]["content"][0]["text"]) + task_id = task_info["taskId"] + + job_id, _ = _unwrap_handles(app, task_id) + manager = app.callback_map["output.children"]["manager"] + assert manager.job_running(job_id) + + r = _post(client, "tasks/cancel", {"taskId": task_id}, request_id=2) + assert r.status_code == 200 + assert json.loads(r.data)["result"]["status"] == "cancelled" + + deadline = time.time() + 5 + while time.time() < deadline: + if not manager.job_running(job_id): + break + time.sleep(0.1) + assert not manager.job_running(job_id) diff --git a/tests/unit/test_background_callback_signing.py b/tests/unit/test_background_callback_signing.py new file mode 100644 index 0000000000..e268cb7e6e --- /dev/null +++ b/tests/unit/test_background_callback_signing.py @@ -0,0 +1,219 @@ +"""Unit tests for background-callback handle signing. + +Covers both the low-level signing helpers and the end-to-end request handling +that closes two vulnerabilities in the DiskcacheManager background-callback flow: + +1. Arbitrary process termination via a client-supplied ``job``/``oldJob``/ + ``cancelJob`` PID. +2. Arbitrary result-cache read/delete via a client-supplied ``cacheKey``. +""" +import re +import subprocess +import sys +import tempfile +import time + +import pytest + +from dash import _callback_signing as signing + +diskcache = pytest.importorskip("diskcache") +psutil = pytest.importorskip("psutil") + + +# --------------------------------------------------------------------------- # +# Low-level signing helpers +# --------------------------------------------------------------------------- # +def test_sign_roundtrips(): + secret = b"s3cr3t" + scope = signing.job_scope("renderer-1") + signed = signing.sign(secret, scope, "4242") + assert signed != "4242" + assert signing.unsign(secret, scope, signed) == "4242" + + +def test_unsign_rejects_forged_value(): + secret = b"s3cr3t" + scope = signing.job_scope("renderer-1") + # A raw pid with no signature, and a made-up signature, both fail. + assert signing.unsign(secret, scope, "9999") is None + assert signing.unsign(secret, scope, "9999~deadbeef") is None + + +def test_unsign_rejects_wrong_scope_or_secret(): + secret = b"s3cr3t" + signed = signing.sign(secret, signing.job_scope("renderer-1"), "4242") + # Same value, different page load (end_id). + assert signing.unsign(secret, signing.job_scope("renderer-2"), signed) is None + # Same value, different field scope. + assert signing.unsign(secret, signing.cache_scope("renderer-1"), signed) is None + # Different secret. + assert signing.unsign(b"other", signing.job_scope("renderer-1"), signed) is None + + +def test_unsign_handles_missing_token(): + secret = b"s3cr3t" + scope = signing.job_scope(None) + assert signing.unsign(secret, scope, None) is None + assert signing.unsign(secret, scope, "") is None + + +# --------------------------------------------------------------------------- # +# End-to-end request handling +# --------------------------------------------------------------------------- # +def _make_app(cache_dir=None): + from dash import Dash, DiskcacheManager, html, Input, Output + + manager = DiskcacheManager(diskcache.Cache(cache_dir or tempfile.mkdtemp())) + app = Dash(__name__, background_callback_manager=manager) + app.layout = html.Div([html.Button(id="btn"), html.Div(id="out")]) + + @app.callback( + Output("out", "children"), + Input("btn", "n_clicks"), + background=True, + prevent_initial_call=True, + ) + def slow(_n): + return "done" + + return app, manager + + +_BODY = { + "output": "out.children", + "outputs": {"id": "out", "property": "children"}, + "inputs": [{"id": "btn", "property": "n_clicks", "value": 1}], + "state": [], + "changedPropIds": ["btn.n_clicks"], +} + + +def test_forged_oldjob_does_not_kill_process(): + app, _ = _make_app() + client = app.server.test_client() + client.get("/") + + victim = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + time.sleep(0.2) + assert psutil.pid_exists(victim.pid) + + resp = client.post(f"/_dash-update-component?oldJob={victim.pid}", json=_BODY) + assert resp.status_code in (200, 204) + time.sleep(0.3) + + assert psutil.pid_exists(victim.pid) + assert psutil.Process(victim.pid).status() != psutil.STATUS_ZOMBIE + finally: + victim.kill() + + +def test_forged_cachekey_is_not_read_or_deleted(): + app, manager = _make_app() + client = app.server.test_client() + client.get("/") + + manager.handle.set("operator-secret-key", {"secret": "topsecret"}) + resp = client.post( + "/_dash-update-component?cacheKey=operator-secret-key&job=0", json=_BODY + ) + body = resp.get_data(as_text=True) + assert "topsecret" not in body + assert manager.handle.get("operator-secret-key") == {"secret": "topsecret"} + + +def test_signed_handles_roundtrip_for_legit_client(): + app, _ = _make_app() + client = app.server.test_client() + + index = client.get("/").get_data(as_text=True) + cfg_match = re.search(r'id="_dash-config"[^>]*>(.*?)', index, re.S) + assert cfg_match, "config not found in index" + import json + + end_id = json.loads(cfg_match.group(1)).get("end_id") + assert end_id and signing._SEP in end_id + + setup = client.post( + f"/_dash-update-component?endId={end_id}", json=_BODY + ).get_json() + signed_cache = setup["cacheKey"] + signed_job = setup["job"] + # Handles handed to the browser are signed, not raw. + assert signing._SEP in signed_cache + assert signing._SEP in signed_job + + result = None + for _ in range(50): + poll = client.post( + f"/_dash-update-component?endId={end_id}" + f"&cacheKey={signed_cache}&job={signed_job}", + json=_BODY, + ).get_json() + response = (poll or {}).get("response") or {} + if response.get("out", {}).get("children") == "done": + result = "done" + break + time.sleep(0.2) + + assert result == "done" + + +# --------------------------------------------------------------------------- # +# Signing secret resolution +# --------------------------------------------------------------------------- # +def test_secret_key_takes_precedence(): + app, _ = _make_app() + app.server.secret_key = "configured-secret" + assert app._get_signing_secret() == b"configured-secret" + + +def test_secret_is_shared_across_workers_via_cache(): + # Two apps standing in for two workers, backed by the SAME diskcache dir and + # neither with a secret_key, must derive the same signing secret. + shared_dir = tempfile.mkdtemp() + app_a, _ = _make_app(cache_dir=shared_dir) + app_b, _ = _make_app(cache_dir=shared_dir) + assert not app_a.server.secret_key + assert not app_b.server.secret_key + + secret_a = app_a._get_signing_secret() + secret_b = app_b._get_signing_secret() + assert secret_a == secret_b + assert isinstance(secret_a, bytes) and len(secret_a) >= 32 + + +def test_secret_differs_for_unshared_caches(): + app_a, _ = _make_app() + app_b, _ = _make_app() + assert app_a._get_signing_secret() != app_b._get_signing_secret() + + +# --------------------------------------------------------------------------- # +# MCP tasks/* surface (same job/cacheKey round-trip, no end_id) +# --------------------------------------------------------------------------- # +def test_mcp_parse_task_id_accepts_signed_handles(): + from dash.mcp.tasks.tasks import parse_task_id + + app, _ = _make_app() + secret = app._get_signing_secret() + # MCP dispatch has no end_id, so handles are signed with a None scope. + signed_job = signing.sign(secret, signing.job_scope(None), "4242") + signed_cache = signing.sign(secret, signing.cache_scope(None), "abc123") + task_id = f"mytool:{signed_job}:{signed_cache}:0" + + tool_name, job_id, cache_key, _created = parse_task_id(task_id) + assert tool_name == "mytool" + assert job_id == "4242" + assert cache_key == "abc123" + + +def test_mcp_parse_task_id_rejects_forged_handles(): + from dash.mcp.tasks.tasks import parse_task_id + from dash.mcp.types import MCPError + + _make_app() + # A raw pid + cache key with no valid signature (what an attacker sends). + with pytest.raises(MCPError): + parse_task_id("mytool:9999:operator-secret-key:0") From 0c3d545953e75963b4d858c22f7b3c85f454c587 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 17 Jul 2026 08:38:27 -0400 Subject: [PATCH 2/5] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ca3b800c..2dd75e30f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ## Fixed +- 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. - [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). - [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). - [#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). From b8ed27cc4b17097163177201cc995740ff0b0681 Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 17 Jul 2026 09:01:18 -0400 Subject: [PATCH 3/5] fix lint --- tests/unit/test_background_callback_signing.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/test_background_callback_signing.py b/tests/unit/test_background_callback_signing.py index e268cb7e6e..09900c72a4 100644 --- a/tests/unit/test_background_callback_signing.py +++ b/tests/unit/test_background_callback_signing.py @@ -193,6 +193,14 @@ def test_secret_differs_for_unshared_caches(): # --------------------------------------------------------------------------- # # MCP tasks/* surface (same job/cacheKey round-trip, no end_id) # --------------------------------------------------------------------------- # +# MCP requires Python 3.10+ (its pydantic models use `X | None` syntax), so +# these tests are skipped on older interpreters, matching the MCP test tree. +requires_mcp = pytest.mark.skipif( + sys.version_info < (3, 10), reason="MCP requires Python 3.10+" +) + + +@requires_mcp def test_mcp_parse_task_id_accepts_signed_handles(): from dash.mcp.tasks.tasks import parse_task_id @@ -209,6 +217,7 @@ def test_mcp_parse_task_id_accepts_signed_handles(): assert cache_key == "abc123" +@requires_mcp def test_mcp_parse_task_id_rejects_forged_handles(): from dash.mcp.tasks.tasks import parse_task_id from dash.mcp.types import MCPError From 6505036e044111e8c7f80ead7acaf9ac10580f2a Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 17 Jul 2026 09:22:15 -0400 Subject: [PATCH 4/5] fix lint --- CHANGELOG.md | 2 +- .../test_background_callback_serialisation.py | 4 +++ .../tools/test_mcp_background_callbacks.py | 32 ++++++++++++++++--- .../unit/test_background_callback_signing.py | 7 ++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd75e30f5..1a8bbd8884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ## Fixed -- 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. +- [#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. - [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). - [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). - [#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). diff --git a/tests/unit/library/test_background_callback_serialisation.py b/tests/unit/library/test_background_callback_serialisation.py index 1502ed3df7..4b3891ebf1 100644 --- a/tests/unit/library/test_background_callback_serialisation.py +++ b/tests/unit/library/test_background_callback_serialisation.py @@ -83,8 +83,12 @@ def fixture_patched_app(): """Patch ``get_app`` so ``_get_callback_manager`` can resolve an adapter.""" adapter = mock.Mock() adapter.args.getlist.return_value = [] + # No signed handles on the request (endId/cacheKey/job absent). + adapter.args.get.return_value = None app = mock.Mock() app.backend.request_adapter.return_value = adapter + # Real bytes secret so the handle-signing in _setup_background_callback works. + app._get_signing_secret.return_value = b"test-secret" with mock.patch.object(_callback, "get_app", return_value=app): yield app diff --git a/tests/unit/mcp/tools/test_mcp_background_callbacks.py b/tests/unit/mcp/tools/test_mcp_background_callbacks.py index ffd4fb5bac..8a9b96bbb8 100644 --- a/tests/unit/mcp/tools/test_mcp_background_callbacks.py +++ b/tests/unit/mcp/tools/test_mcp_background_callbacks.py @@ -12,13 +12,32 @@ import time import diskcache -from dash import Dash, Input, Output, html +from dash import Dash, Input, Output, html, _callback_signing from dash.background_callback.managers.diskcache_manager import DiskcacheManager from dash.mcp._server import _process_mcp_message from tests.unit.mcp.conftest import _setup_mcp +def _unwrap_handles(app, task_id): + """Return the raw (unsigned) ``(job_id, cache_key)`` from a signed taskId. + + The handles embedded in a taskId are HMAC-signed (see ``_callback_signing``); + tests that poke the manager directly must unwrap them first. MCP dispatch has + no end_id, so the ``None`` end_id scope is used. + """ + secret = app._get_signing_secret() + _tool, signed_job, rest = task_id.split(":", 2) + signed_cache, _epoch = rest.rsplit(":", 1) + job_id = _callback_signing.unsign( + secret, _callback_signing.job_scope(None), signed_job + ) + cache_key = _callback_signing.unsign( + secret, _callback_signing.cache_scope(None), signed_cache + ) + return job_id, cache_key + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -74,7 +93,7 @@ def _trigger_task(app): def _wait_for_completion(app, task_id, timeout=3): """Block until the callback manager reports the job is no longer running.""" - _, job_id, _ = task_id.split(":", 2) + job_id, _ = _unwrap_handles(app, task_id) manager = app.callback_map["output.children"]["manager"] deadline = time.time() + timeout while time.time() < deadline: @@ -102,7 +121,7 @@ def test_mcpbg001_cancel_via_tool(): ) assert cancel["result"].get("isError") is not True - _, job_id, _ = task_id.split(":", 2) + job_id, _ = _unwrap_handles(app, task_id) manager = app.callback_map["output.children"]["manager"] assert not manager.job_running(job_id) @@ -233,7 +252,7 @@ def test_mcpbg009_tasks_cancel_terminates_job(): cancel_result = _mcp(app, "tasks/cancel", {"taskId": task_id}) assert "error" not in cancel_result - _, job_id, _ = task_id.split(":", 2) + job_id, _ = _unwrap_handles(app, task_id) manager = app.callback_map["output.children"]["manager"] assert not manager.job_running(job_id) @@ -272,7 +291,10 @@ def test_mcpbg011_task_id_encodes_tool_name_job_id_cache_key(): }, ) task_id = result["result"]["task"]["taskId"] - tool_name, _job_id, cache_key, created_epoch = task_id.split(":") + tool_name, _signed_job, _signed_cache, created_epoch = task_id.split(":") assert tool_name == "slow_callback" + # Handles are signed; unwrapping recovers the raw job id and SHA256 cache key. + job_id, cache_key = _unwrap_handles(app, task_id) + assert job_id.isdigit() assert len(cache_key) == 64 # SHA256 hex assert created_epoch.isdigit() diff --git a/tests/unit/test_background_callback_signing.py b/tests/unit/test_background_callback_signing.py index 09900c72a4..0ed17689b0 100644 --- a/tests/unit/test_background_callback_signing.py +++ b/tests/unit/test_background_callback_signing.py @@ -202,10 +202,13 @@ def test_secret_differs_for_unshared_caches(): @requires_mcp def test_mcp_parse_task_id_accepts_signed_handles(): + from dash import get_app from dash.mcp.tasks.tasks import parse_task_id - app, _ = _make_app() - secret = app._get_signing_secret() + _make_app() + # Sign with the same secret parse_task_id resolves (via get_app), so the + # signatures verify regardless of which app instance get_app returns. + secret = get_app()._get_signing_secret() # MCP dispatch has no end_id, so handles are signed with a None scope. signed_job = signing.sign(secret, signing.job_scope(None), "4242") signed_cache = signing.sign(secret, signing.cache_scope(None), "abc123") From cf6cb453aa47653c7a781907c6cae2d18ce73c1b Mon Sep 17 00:00:00 2001 From: philippe Date: Fri, 17 Jul 2026 09:33:11 -0400 Subject: [PATCH 5/5] fix lint --- tests/unit/test_health_endpoint_unit.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_health_endpoint_unit.py b/tests/unit/test_health_endpoint_unit.py index 723591fba4..b95855cc33 100644 --- a/tests/unit/test_health_endpoint_unit.py +++ b/tests/unit/test_health_endpoint_unit.py @@ -20,8 +20,11 @@ def test_health_disabled_by_default_returns_404(): # When health endpoint is disabled, it returns the main page (200) instead of 404 # This is expected behavior - the health endpoint is not available assert r.status_code == 200 - # Should return HTML content, not "OK" - assert b"OK" not in r.data + # Should return the index HTML page, not the plain-text "OK" health response. + # (A substring check for b"OK" is unreliable: the page embeds random tokens + # such as the signing end_id that can contain those letters by chance.) + assert r.mimetype == "text/html" + assert r.data != b"OK" def test_health_enabled_returns_ok_200_plain_text():