Skip to content

Fix security findings reported against 4.5.0rc1 - #4075

Open
T4rk1n wants to merge 2 commits into
devfrom
fix/security-4.5.0rc1-report
Open

T4rk1n wants to merge 2 commits into
devfrom
fix/security-4.5.0rc1-report

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Fixes five issues from a responsible-disclosure report against 4.5.0rc1.

FastAPI before_request hooks (dash/backends/_fastapi.py)
Hooks only ran for _dash-* routes, the returned response was dropped, and nothing ran when the app was mounted under a parent FastAPI app. Hooks now run for every route of the Dash app (assets, hook routes, api_endpoint, pages), use the path relative to root_path, and gate the WebSocket handshake. A non-None result is sent as the response (Response, body, or (body, status[, headers])), and HTTPException keeps its status. User routes still skip the JSON body read and keep their own error handling.

Background callback result keys (background_callback/managers/__init__.py)
The key was sha256(args, fn source, triggered). The signed endId handle stops forged keys, but a second client sending the same inputs got a valid handle for the same raw key and could read another user's result. Without cache_by each run now gets a random nonce, and the callback key is always part of the hash, so callbacks from one factory function no longer collide.

URL sanitizer gaps (Location.react.js, html generate-components.js)
dcc.Location(refresh=True).href and html.Area.href now go through clean_url, raising the same "Dangerous link detected" error as dcc.Link.

Pages path_template matching (dash/_pages.py)
A newline in the pathname passed the fnmatch gate but not the unanchored (.*) regex, giving an IndexError and polynomial backtracking. Matching now uses a precompiled DOTALL fullmatch after the gate. Variables can still contain / as before.

MCP request isolation (dash/dash.py, dash/mcp/_server.py)
app.mcp_callback_map cached callback initial outputs and layout values from the first caller for the whole process. It is now a per-app ContextVar, set to a fresh collection for each MCP message.

Behavior changes

  • FastAPI: a hook that returns any non-None value now short-circuits the request, as on Flask.
  • build_cache_key takes a new callback_key argument.
  • With cache_by unset, identical triggers no longer reuse a result.

Tests

Each new test fails on dev and passes here:

  • tests/backend_tests/test_fastapi_before_request.py
  • tests/integration/security/test_background_isolation.py
  • tests/integration/security/test_xss.py (xss004, xss005, checked against bundles built from unpatched sources)
  • tests/integration/multi_page/test_pages_path_template.py
  • tests/integration/mcp/test_mcp_request_isolation.py

tests/unit, tests/backend_tests, tests/integration/{mcp,multi_page,security} and tests/background_callback pass locally.

- fastapi: run before_request hooks on every route, when mounted and on the
  WebSocket handshake, and send a hook's returned response.
- Background callbacks: key results per run when cache_by is unset and per
  callback always, so equal inputs never share another request's result.
- dcc.Location(refresh=True).href and html.Area.href go through clean_url.
- Pages path_template matching: no IndexError or regex backtracking on
  pathnames containing a newline.
- MCP: build the callback adapter collection per request so values computed
  for one user are not served to another.
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Dash performance benchmarks

✅ all within thresholds

scenario metric p90 (ms) median growth baseline p90 note
✅ callback_chain chain_ms 305.7 289.6 0.9x 499.1
✅ callback_chain graph_ms 1.3 1.3 1.0x 2.4
✅ callback_fanout fanout_ms 56.9 54.1 0.86x 92.5
✅ deep_nesting render_ms 39.4 33.9 0.96x 56.8
✅ full_children_replace replace_ms 3609.8 1172.6 19.54x 4697.1
✅ initial_render_large render_ms 415.2 364.5 1.09x 694.4
✅ initial_render_small render_ms 63.3 60.6 0.9x 104.0
✅ patch_append_nested append_ms 111.7 68.4 2.45x 192.3
✅ patch_append_toplevel append_ms 83.9 66.4 2.34x 140.2
✅ patch_scalar_update_large update_ms 143.9 114.0 0.99x 202.9
✅ wildcard_all_resolve wildcard_ms 175.3 167.1 0.95x 313.6
✅ wildcard_all_resolve graph_ms 0.8 0.8 1.0x 1.3

growth = late-third / early-third per-op time; ~1 is flat, a large value means the per-op cost scales with accumulated state.

machine scale vs baseline: 0.63x - divided out of the baseline ratios so they compare like for like (the absolute warn/fail ceilings are left un-scaled); calibrated on initial_render_small.

Comment thread dash/backends/_fastapi.py
return result
status, headers = 200, None
if isinstance(result, tuple):
result, status, *rest = result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unpacks every tuple as (body, status[, headers]) but (body, headers) is also valid.
This example breaks:

from dash import Dash, html

app = Dash(__name__, backend="fastapi")  # also try "flask" or "quart"
app.layout = html.Div("hi")


def deny():
    return ("denied", {"X-Test": "1"})


app.backend.before_request(deny)

Comment thread dash/backends/_fastapi.py
Comment on lines +376 to +377
except Exception: # pylint: disable=broad-exception-caught
denied = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we close the handshake without logging. A bug in a hook would block every Websocket and leave nothing to debug. I think we should log the exception before denying.

Related: with a hook that denies everyone, FastAPI now rejects the websocket handshake but Quart still connects. Do you care about the inconsistency there?

Comment on lines +103 to +105
# Without caching the job runs in the requester's context, so two
# requests with equal inputs must never share a result.
hash_dict["nonce"] = secrets.token_hex(16)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With cache_by unset, the output of a background callback can now stay in the cache forever.

Example: a user clicks a button that starts a slow background callback, then refreshes the page before it finishes. The callback still finishes and saves its output, but the refreshed page never asks for it.

Before this change, the next identical click reused the same cache entry and replaced the old output. Now every click gets its own entry, so each refresh leaves one more saved output behind.

We should set an expiry on these outputs, or find some mechanism to safely remove stale entries.

Comment thread dash/mcp/_server.py
Comment on lines +262 to +270
app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS)
# A fresh collection per message: its adapters cache values computed from
# this caller's request, which must never be served to another user.
# pylint: disable-next=protected-access
token = app._mcp_callback_map.set(CallbackAdapterCollection(app))
try:
return _dispatch_mcp_message(method, params, request_id)
finally:
app._mcp_callback_map.reset(token) # pylint: disable=protected-access

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a fresh mcp_callback_map per request will cause significant performance problems in larger apps.

Each request throws away everything the adapters compute lazily. MCP clients in their normal usage will have to walk the layout multiple times and re-run every callback to compute their initial values. (example: a tools/list call will recompute everything every time)

I would suggest keeping the cached mcp_callback_map and instead allow app authors to work around the narrow security issue by supplying their own cache keys. Basically, following the same pattern established by background callbacks’ cache_by kwarg (with a default value that preserves the caching we have today).

Comment on lines +262 to +263
button: createXSSProtection('formAction'),
area: createXSSProtection('href')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just calling out that html.Base.href remains unsanitized here. Not sure if that's intentional.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants