CVE-2024-21485
Description
Plotly Dash packages dash-core-components, dash-html-components, and dash are vulnerable to cross-site scripting due to insufficient sanitization of href attributes, allowing authenticated attackers to steal data.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Plotly Dash packages dash-core-components, dash-html-components, and dash are vulnerable to cross-site scripting due to insufficient sanitization of href attributes, allowing authenticated attackers to steal data.
Vulnerability
Overview
The vulnerability is a stored cross-site scripting (XSS) issue in several Plotly Dash packages: dash-core-components, dash-html-components, and dash. The root cause is the lack of sanitization of the href attribute in HTML elements such as `, , and tags. An attacker can inject arbitrary JavaScript by providing a malicious href value (e.g., javascript:alert(1)`) that is later rendered in another user's browser [1][2][3].
Exploitation
Exploitation requires an authenticated attacker who can store a crafted view containing malicious href attributes. When another authenticated user opens that view, the injected script executes in the context of the victim's session. The attack is only feasible in Dash apps that include a mechanism to store user input and reload it for different users [3][4].
Impact
Successful exploitation allows the attacker to steal any data visible on the page, make additional API requests on behalf of the victim, and potentially access sensitive resources or user access tokens. In severe cases, the attacker could fully impersonate the victim, gaining access to other apps and data hosted on the same server [2][3][4].
Mitigation
Plotly has addressed this vulnerability with fixes in version 2.15.0 of dash, version 2.0.0 of dash-core-components, and version 2.0.16 of dash-html-components. Users should upgrade to these versions or later to prevent exploitation [1][3][4]. No workarounds have been published; updating is the recommended action.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
dash-core-componentsnpm | < 2.13.0 | 2.13.0 |
dash-html-componentsPyPI | < 2.0.0 | 2.0.0 |
dash-core-componentsPyPI | < 2.0.0 | 2.0.0 |
dashPyPI | < 2.15.0 | 2.15.0 |
dash-html-componentsnpm | < 2.0.16 | 2.0.16 |
Affected products
6- ghsa-coords5 versionspkg:npm/dash-core-componentspkg:npm/dash-html-componentspkg:pypi/dashpkg:pypi/dash-core-componentspkg:pypi/dash-html-components
< 2.13.0+ 4 more
- (no CPE)range: < 2.13.0
- (no CPE)range: < 2.0.16
- (no CPE)range: < 2.15.0
- (no CPE)range: < 2.0.0
- (no CPE)range: < 2.0.0
Patches
19920073c9a86Merge pull request #2732 from plotly/fix/xss-props
16 files changed · +9448 −7913
CHANGELOG.md+6 −1 modified@@ -4,8 +4,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] -### Added +## Added - [#2695](https://github.com/plotly/dash/pull/2695) Adds `triggered_id` to `dash_clientside.callback_context`. Fixes [#2692](https://github.com/plotly/dash/issues/2692) +- [#2732](https://github.com/plotly/dash/pull/2732) Add special key `_dash_error` to `setProps`, allowing component developers to send error without throwing in render. Usage `props.setProps({_dash_error: new Error("custom error")})` + +## Fixed + +- [#2732](https://github.com/plotly/dash/pull/2732) Sanitize html props that are vulnerable to xss vulnerability if user data is inserted. Fix Validate url to prevent XSS attacks [#2729](https://github.com/plotly/dash/issues/2729) ## Changed - [#2652](https://github.com/plotly/dash/pull/2652) dcc.Clipboard supports htm_content and triggers a copy to clipboard when n_clicks are changed
components/dash-core-components/package.json+8 −6 modified@@ -35,6 +35,7 @@ "maintainer": "Alex Johnson <alex@plotly.com>", "license": "MIT", "dependencies": { + "@braintree/sanitize-url": "^7.0.0", "@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", @@ -49,7 +50,7 @@ "moment": "^2.29.4", "node-polyfill-webpack-plugin": "^2.0.1", "prop-types": "^15.8.1", - "ramda": "^0.29.0", + "ramda": "^0.29.1", "rc-slider": "^9.7.5", "react-addons-shallow-compare": "^15.6.3", "react-dates": "^21.8.0", @@ -64,11 +65,11 @@ "uniqid": "^5.4.0" }, "devDependencies": { - "@babel/cli": "^7.23.0", - "@babel/core": "^7.23.0", + "@babel/cli": "^7.23.4", + "@babel/core": "^7.23.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/preset-env": "^7.22.20", - "@babel/preset-react": "^7.22.15", + "@babel/preset-env": "^7.23.8", + "@babel/preset-react": "^7.23.3", "@plotly/dash-component-plugins": "^1.2.3", "@plotly/webpack-dash-dynamic-import": "^1.3.0", "babel-loader": "^9.1.3", @@ -88,9 +89,10 @@ "react-jsx-parser": "1.21.0", "style-loader": "^3.3.3", "styled-jsx": "^3.4.4", - "webpack": "^5.88.2", + "webpack": "^5.90.0", "webpack-cli": "^5.1.4" }, + "optionalDependencies": { "fsevents": "*" }, "files": [ "/dash_core_components/*{.js,.map}", "/lib/"
components/dash-core-components/package-lock.json+2949 −2144 modifiedcomponents/dash-core-components/src/components/Link.react.js+47 −46 modified@@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; -import React, {Component} from 'react'; - +import React, {useEffect, useMemo} from 'react'; +import {sanitizeUrl} from '@braintree/sanitize-url'; import {isNil} from 'ramda'; /* @@ -33,15 +33,23 @@ CustomEvent.prototype = window.Event.prototype; * For links with destinations outside the current app, `html.A` is a better * component to use. */ -export default class Link extends Component { - constructor(props) { - super(props); - this.updateLocation = this.updateLocation.bind(this); - } +const Link = props => { + const { + className, + style, + id, + href, + loading_state, + children, + title, + target, + refresh, + setProps, + } = props; + const sanitizedUrl = useMemo(() => sanitizeUrl(href), [href]); - updateLocation(e) { + const updateLocation = e => { const hasModifiers = e.metaKey || e.shiftKey || e.altKey || e.ctrlKey; - const {href, refresh, target} = this.props; if (hasModifiers) { return; @@ -52,49 +60,40 @@ export default class Link extends Component { // prevent anchor from updating location e.preventDefault(); if (refresh) { - window.location = href; + window.location = sanitizedUrl; } else { - window.history.pushState({}, '', href); + window.history.pushState({}, '', sanitizedUrl); window.dispatchEvent(new CustomEvent('_dashprivate_pushstate')); } // scroll back to top window.scrollTo(0, 0); - } + }; - render() { - const { - className, - style, - id, - href, - loading_state, - children, - title, - target, - } = this.props; - /* - * ideally, we would use cloneElement however - * that doesn't work with dash's recursive - * renderTree implementation for some reason - */ - return ( - <a - data-dash-is-loading={ - (loading_state && loading_state.is_loading) || undefined - } - id={id} - className={className} - style={style} - href={href} - onClick={e => this.updateLocation(e)} - title={title} - target={target} - > - {isNil(children) ? href : children} - </a> - ); - } -} + useEffect(() => { + if (sanitizedUrl !== href) { + setProps({ + _dash_error: new Error(`Dangerous link detected:: ${href}`), + }); + } + }, [href, sanitizedUrl]); + + return ( + <a + data-dash-is-loading={ + (loading_state && loading_state.is_loading) || undefined + } + id={id} + className={className} + style={style} + href={sanitizedUrl} + onClick={updateLocation} + title={title} + target={target} + > + {isNil(children) ? sanitizedUrl : children} + </a> + ); +}; Link.propTypes = { /** @@ -151,8 +150,10 @@ Link.propTypes = { */ component_name: PropTypes.string, }), + setProps: PropTypes.func, }; Link.defaultProps = { refresh: false, }; +export default Link;
components/dash-html-components/package.json+10 −13 modified@@ -28,16 +28,15 @@ "author": "Chris Parmer <chris@plotly.com>", "maintainer": "Alex Johnson <alex@plotly.com>", "dependencies": { + "@braintree/sanitize-url": "^7.0.0", "prop-types": "^15.8.1", - "ramda": "^0.29.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "ramda": "^0.29.0" }, "devDependencies": { - "@babel/cli": "^7.23.0", - "@babel/core": "^7.23.0", - "@babel/preset-env": "^7.22.20", - "@babel/preset-react": "^7.22.15", + "@babel/cli": "^7.23.4", + "@babel/core": "^7.23.7", + "@babel/preset-env": "^7.23.8", + "@babel/preset-react": "^7.23.3", "babel-loader": "^9.1.3", "cheerio": "^0.22.0", "cross-env": "^7.0.3", @@ -49,16 +48,14 @@ "react-docgen": "^5.4.3", "request": "^2.88.2", "string": "^3.3.3", - "webpack": "^5.88.2", - "webpack-cli": "^5.1.4" + "webpack": "^5.90.0", + "webpack-cli": "^5.1.4", + "react": "^16.14.0", + "react-dom": "^16.14.0" }, "files": [ "/dash_html_components/*{.js,.map}" ], - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - }, "browserslist": [ "last 8 years and not dead" ]
components/dash-html-components/package-lock.json+2786 −1934 modifiedcomponents/dash-html-components/scripts/generate-components.js+43 −4 modified@@ -249,27 +249,66 @@ const customDocs = { * <body>.` }; +const customImportsForComponents = { + a: `import {sanitizeUrl} from '@braintree/sanitize-url';`, + form: `import {sanitizeUrl} from '@braintree/sanitize-url';`, + iframe: `import {sanitizeUrl} from '@braintree/sanitize-url';`, + object: `import {sanitizeUrl} from '@braintree/sanitize-url';`, + embed: `import {sanitizeUrl} from '@braintree/sanitize-url';`, + button: `import {sanitizeUrl} from '@braintree/sanitize-url';`, +} + +function createXSSProtection(propName) { + return ` + const ${propName} = React.useMemo(() => props.${propName} && sanitizeUrl(props.${propName}), [props.${propName}]); + + if (${propName}) { + extraProps.${propName} = ${propName}; + } + + React.useEffect(() => { + if (${propName} && ${propName} !== props.${propName}) { + props.setProps({_dash_error: new Error(\`Dangerous link detected: \${props.${propName}}\`)}) + } + }, [props.${propName}, ${propName}]); + ` +} + + +const customCodesForComponents = { + a: createXSSProtection('href'), + form: createXSSProtection('action'), + iframe: createXSSProtection('src'), + object: createXSSProtection('data'), + embed: createXSSProtection('src'), + button: createXSSProtection('formAction') +} + function generateComponent(Component, element, attributes) { const propTypes = generatePropTypes(element, attributes); + const customImport = customImportsForComponents[element] || ''; const customDoc = customDocs[element] ? ('\n *' + customDocs[element] + '\n *') : ''; + const customCode = customCodesForComponents[element] || ''; + return ` import React from 'react'; import PropTypes from 'prop-types'; import {omit} from 'ramda'; +${customImport} /** * ${Component} is a wrapper for the <${element}> HTML5 element.${customDoc} * For detailed attribute info see: * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/${element} */ const ${Component} = (props) => { - const dataAttributes = {}; + const extraProps = {}; if(props.loading_state && props.loading_state.is_loading) { - dataAttributes['data-dash-is-loading'] = true; + extraProps['data-dash-is-loading'] = true; } - +${customCode} /* remove unnecessary onClick event listeners */ const isStatic = props.disable_n_clicks || !props.id; return ( @@ -281,7 +320,7 @@ const ${Component} = (props) => { }) })} {...omit(['n_clicks', 'n_clicks_timestamp', 'loading_state', 'setProps', 'disable_n_clicks'], props)} - {...dataAttributes} + {...extraProps} > {props.children} </${element}>
components/dash-table/package.json+8 −8 modified@@ -41,13 +41,13 @@ "maintainer": "Alex Johnson <alex@plotly.com>", "license": "MIT", "devDependencies": { - "@babel/cli": "^7.23.0", - "@babel/core": "^7.23.0", + "@babel/cli": "^7.23.4", + "@babel/core": "^7.23.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-regenerator": "^7.22.10", + "@babel/plugin-transform-regenerator": "^7.23.3", "@babel/polyfill": "^7.12.1", - "@babel/preset-env": "^7.22.20", - "@babel/preset-react": "^7.22.15", + "@babel/preset-env": "^7.23.8", + "@babel/preset-react": "^7.23.3", "@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", @@ -92,7 +92,7 @@ "npm-run-all": "^4.1.5", "papaparse": "^5.4.1", "prettier": "^2.8.8", - "ramda": "^0.29.0", + "ramda": "^0.29.1", "raw-loader": "^4.0.2", "react": "^16.14.0", "react-docgen": "^5.4.3", @@ -103,8 +103,8 @@ "sheetclip": "^0.3.0", "style-loader": "^3.3.3", "ts-loader": "^9.4.3", - "typescript": "^5.0.4", - "webpack": "^5.88.2", + "typescript": "^5.3.3", + "webpack": "^5.90.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", "webpack-preprocessor": "^0.1.12",
components/dash-table/package-lock.json+3519 −3746 modifiedcomponents/dash-table/src/dash-table/components/ControlledTable/index.tsx+2 −2 modified@@ -1207,12 +1207,12 @@ export default class ControlledTable extends PureComponent<ControlledTableProps> ) => { const {columns, hidden_columns: base, setProps} = this.props; - const ids: string[] = actions.getColumnIds( + const ids = actions.getColumnIds( column, columns, headerRowIndex, mergeDuplicateHeaders - ); + ) as string[]; const hidden_columns = base ? base.slice(0) : []; ids.forEach(id => {
components/dash-table/src/dash-table/components/Export/utils.tsx+2 −0 modified@@ -72,6 +72,8 @@ export async function createWorkbook( const ws = XLSX.utils.aoa_to_sheet([]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore data = R.map(R.pick(columnID))(data); if (
components/dash-table/src/dash-table/derived/header/content.tsx+1 −1 modified@@ -418,7 +418,7 @@ function getter( hiddenColumns ? R.union( hiddenColumns, - ids + ids as string[] ) : ids;
components/dash-table/src/dash-table/derived/style/index.ts+2 −0 modified@@ -103,6 +103,8 @@ function convertElement(style: GenericStyle): IConvertedStyle { } function convertStyle(style: Style): CSSProperties { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore return R.reduce<[string, StyleProperty?], any>( (res, [key, value]) => { if (converter.has(key)) {
dash/dash-renderer/package-lock.json+6 −6 modified@@ -5752,9 +5752,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "dev": true, "funding": [ { @@ -14865,9 +14865,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "dev": true }, "for-each": {
dash/dash-renderer/src/TreeContainer.js+12 −2 modified@@ -23,7 +23,7 @@ import { pathOr, type } from 'ramda'; -import {notifyObservers, updateProps} from './actions'; +import {notifyObservers, updateProps, onError} from './actions'; import isSimpleComponent from './isSimpleComponent'; import {recordUiEdit} from './persistence'; import ComponentErrorBoundary from './components/error/ComponentErrorBoundary.react'; @@ -136,11 +136,21 @@ class BaseTreeContainer extends Component { const oldProps = this.getLayoutProps(); const {id} = oldProps; + const {_dash_error, ...rest} = newProps; const changedProps = pickBy( (val, key) => !equals(val, oldProps[key]), - newProps + rest ); + if (_dash_error) { + _dashprivate_dispatch( + onError({ + type: 'frontEnd', + error: _dash_error + }) + ); + } + if (!isEmpty(changedProps)) { _dashprivate_dispatch((dispatch, getState) => { const {graphs} = getState();
tests/integration/security/test_xss.py+47 −0 added@@ -0,0 +1,47 @@ +from dash import Dash, html, dcc + + +def test_xss001_banned_protocols(dash_duo): + app = Dash() + + app.layout = html.Div( + [ + dcc.Link("dcc-link", href="javascript:alert(1)", id="dcc-link"), + html.Br(), + html.A( + "html.A", href='javascr\nipt:alert(1);console.log("xss");', id="html-A" + ), + html.Br(), + html.Form( + [ + html.Button( + "form-action", + formAction="javascript:alert('form-action')", + id="button-form-action", + ), + html.Button("submit", role="submit"), + ], + action='javascript:alert(1);console.log("xss");', + id="form", + ), + html.Iframe(src='javascript:alert("iframe")', id="iframe-src"), + html.ObjectEl(data='javascript:alert("data-object")', id="object-data"), + html.Embed(src='javascript:alert("embed")', id="embed-src"), + ] + ) + + dash_duo.start_server(app) + + for element_id, prop in ( + ("#dcc-link", "href"), + ("#html-A", "href"), + ("#iframe-src", "src"), + ("#object-data", "data"), + ("#embed-src", "src"), + ("#button-form-action", "formAction"), + ): + + element = dash_duo.find_element(element_id) + assert ( + element.get_attribute(prop) == "about:blank" + ), f"Failed prop: {element_id}.{prop}"
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
12- github.com/advisories/GHSA-547x-748v-vp6pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-21485ghsaADVISORY
- github.com/plotly/dash/commit/9920073c9a8619ae8f90fcec1924f2f3a4332a8cghsaWEB
- github.com/plotly/dash/issues/2729ghsaWEB
- github.com/plotly/dash/pull/2732ghsaWEB
- github.com/plotly/dash/releases/tag/v2.15.0ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/dash/PYSEC-2024-35.yamlghsaWEB
- security.snyk.io/vuln/SNYK-JS-DASHCORECOMPONENTS-6183084ghsaWEB
- security.snyk.io/vuln/SNYK-JS-DASHHTMLCOMPONENTS-6226337ghsaWEB
- security.snyk.io/vuln/SNYK-PYTHON-DASH-6226335ghsaWEB
- security.snyk.io/vuln/SNYK-PYTHON-DASHCORECOMPONENTS-6226334ghsaWEB
- security.snyk.io/vuln/SNYK-PYTHON-DASHHTMLCOMPONENTS-6226336ghsaWEB
News mentions
0No linked articles in our index yet.