VYPR
Moderate severityOSV Advisory· Published Feb 2, 2024· Updated May 15, 2025

CVE-2024-21485

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.

PackageAffected versionsPatched versions
dash-core-componentsnpm
< 2.13.02.13.0
dash-html-componentsPyPI
< 2.0.02.0.0
dash-core-componentsPyPI
< 2.0.02.0.0
dashPyPI
< 2.15.02.15.0
dash-html-componentsnpm
< 2.0.162.0.16

Affected products

6

Patches

1
9920073c9a86

Merge pull request #2732 from plotly/fix/xss-props

https://github.com/plotly/dashPhilippe DuvalJan 30, 2024via ghsa
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 modified
  • components/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 modified
  • components/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 modified
  • components/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

News mentions

0

No linked articles in our index yet.