diff --git a/CHANGELOG.md b/CHANGELOG.md index c70c9cf1d19..fbdd380be33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Add support for Kaleido>=v1.0.0 for image generation, and deprecate support for Kaleido<1 and Orca [[#5062](https://github.com/plotly/plotly.py/pull/5062)] - Reduce package bundle size by 18-24% via changes to code generation [[#4978](https://github.com/plotly/plotly.py/pull/4978)] +### Added +- Add SRI (Subresource Integrity) hash support for CDN script tags when using `include_plotlyjs='cdn'`. This enhances security by ensuring browser verification of CDN-served plotly.js files [[#PENDING](https://github.com/plotly/plotly.py/pull/PENDING)] + ### Fixed - Fix third-party widget display issues in v6 [[#5102](https://github.com/plotly/plotly.py/pull/5102)] - Add handling for case where `jupyterlab` or `notebook` is not installed [[#5104](https://github.com/plotly/plotly.py/pull/5104/files)] diff --git a/plotly/io/_html.py b/plotly/io/_html.py index dbec1fc83b7..e7bbad5faa6 100644 --- a/plotly/io/_html.py +++ b/plotly/io/_html.py @@ -1,6 +1,8 @@ import uuid from pathlib import Path import webbrowser +import hashlib +import base64 from _plotly_utils.optional_imports import get_module from plotly.io._utils import validate_coerce_fig_to_dict, plotly_cdn_url @@ -9,6 +11,14 @@ _json = get_module("json") +def _generate_sri_hash(content): + """Generate SHA256 hash for SRI (Subresource Integrity)""" + if isinstance(content, str): + content = content.encode("utf-8") + sha256_hash = hashlib.sha256(content).digest() + return "sha256-" + base64.b64encode(sha256_hash).decode("utf-8") + + # Build script to set global PlotlyConfig object. This must execute before # plotly.js is loaded. _window_plotly_config = """\ @@ -252,11 +262,17 @@ def to_html( load_plotlyjs = "" if include_plotlyjs == "cdn": + # Generate SRI hash from the bundled plotly.js content + plotlyjs_content = get_plotlyjs() + sri_hash = _generate_sri_hash(plotlyjs_content) + load_plotlyjs = """\ {win_config} - <script charset="utf-8" src="{cdn_url}"></script>\ + <script charset="utf-8" src="{cdn_url}" integrity="{integrity}" crossorigin="anonymous"></script>\ """.format( - win_config=_window_plotly_config, cdn_url=plotly_cdn_url() + win_config=_window_plotly_config, + cdn_url=plotly_cdn_url(), + integrity=sri_hash, ) elif include_plotlyjs == "directory": diff --git a/tests/test_core/test_offline/test_offline.py b/tests/test_core/test_offline/test_offline.py index d0a9c80e1cb..53912ef45f2 100644 --- a/tests/test_core/test_offline/test_offline.py +++ b/tests/test_core/test_offline/test_offline.py @@ -37,9 +37,7 @@ <script type="text/javascript">\ window.PlotlyConfig = {MathJaxConfig: 'local'};</script>""" -cdn_script = '<script charset="utf-8" src="{cdn_url}"></script>'.format( - cdn_url=plotly_cdn_url() -) +cdn_script = '<script charset="utf-8" src="{cdn_url}"'.format(cdn_url=plotly_cdn_url()) directory_script = '<script charset="utf-8" src="plotly.min.js"></script>' diff --git a/tests/test_io/test_html.py b/tests/test_io/test_html.py index 67f161af681..230581ec8c1 100644 --- a/tests/test_io/test_html.py +++ b/tests/test_io/test_html.py @@ -7,6 +7,8 @@ import plotly.graph_objs as go import plotly.io as pio from plotly.io._utils import plotly_cdn_url +from plotly.offline.offline import get_plotlyjs +from plotly.io._html import _generate_sri_hash if sys.version_info >= (3, 3): @@ -46,3 +48,45 @@ def test_html_deterministic(fig1): assert pio.to_html(fig1, include_plotlyjs="cdn", div_id=div_id) == pio.to_html( fig1, include_plotlyjs="cdn", div_id=div_id ) + + +def test_cdn_includes_integrity_attribute(fig1): + """Test that the CDN script tag includes an integrity attribute with SHA256 hash""" + html_output = pio.to_html(fig1, include_plotlyjs="cdn") + + # Check that the script tag includes integrity attribute + assert 'integrity="sha256-' in html_output + assert 'crossorigin="anonymous"' in html_output + + # Verify it's in the correct script tag + import re + + cdn_pattern = re.compile( + r'<script[^>]*src="' + + re.escape(plotly_cdn_url()) + + r'"[^>]*integrity="sha256-[A-Za-z0-9+/=]+"[^>]*>' + ) + match = cdn_pattern.search(html_output) + assert match is not None, "CDN script tag with integrity attribute not found" + + +def test_cdn_integrity_hash_matches_bundled_content(fig1): + """Test that the SRI hash in CDN script tag matches the bundled plotly.js content""" + html_output = pio.to_html(fig1, include_plotlyjs="cdn") + + # Extract the integrity hash from the HTML output + import re + + integrity_pattern = re.compile(r'integrity="(sha256-[A-Za-z0-9+/=]+)"') + match = integrity_pattern.search(html_output) + assert match is not None, "Integrity attribute not found" + extracted_hash = match.group(1) + + # Generate expected hash from bundled content + plotlyjs_content = get_plotlyjs() + expected_hash = _generate_sri_hash(plotlyjs_content) + + # Verify they match + assert ( + extracted_hash == expected_hash + ), f"Hash mismatch: expected {expected_hash}, got {extracted_hash}" diff --git a/tests/test_io/test_renderers.py b/tests/test_io/test_renderers.py index d09504af39c..60c63ba6ba4 100644 --- a/tests/test_io/test_renderers.py +++ b/tests/test_io/test_renderers.py @@ -13,6 +13,7 @@ import plotly.io as pio from plotly.offline import get_plotlyjs from plotly.io._utils import plotly_cdn_url +from plotly.io._html import _generate_sri_hash if sys.version_info >= (3, 3): import unittest.mock as mock @@ -298,12 +299,19 @@ def test_repr_html(renderer): # id number of figure id_html = str_html.split('document.getElementById("')[1].split('")')[0] id_pattern = "cd462b94-79ce-42a2-887f-2650a761a144" + + # Calculate the SRI hash dynamically + plotlyjs_content = get_plotlyjs() + sri_hash = _generate_sri_hash(plotlyjs_content) + template = ( '<div> <script type="text/javascript">' "window.PlotlyConfig = {MathJaxConfig: 'local'};</script>\n " '<script charset="utf-8" src="' + plotly_cdn_url() - + '"></script> ' + + '" integrity="' + + sri_hash + + '" crossorigin="anonymous"></script> ' '<div id="cd462b94-79ce-42a2-887f-2650a761a144" class="plotly-graph-div" ' 'style="height:100%; width:100%;"></div> <script type="text/javascript">' " window.PLOTLYENV=window.PLOTLYENV || {};"