Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add support for Kaleido>=v1.0.0 for image generation [[#5062](https://github.com/plotly/plotly.py/pull/5062), [#5177](https://github.com/plotly/plotly.py/pull/5177)]
- 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)]
Expand Down
20 changes: 18 additions & 2 deletions plotly/io/_html.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = """\
Expand Down Expand Up @@ -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":
Expand Down
6 changes: 4 additions & 2 deletions tests/test_core/test_offline/test_offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
import pytest

import plotly
from plotly.offline import get_plotlyjs
import plotly.io as pio
from plotly.io._utils import plotly_cdn_url
from plotly.io._html import _generate_sri_hash

packages_root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(plotly.__file__))))
Expand All @@ -37,8 +39,8 @@
<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}" integrity="{js_hash}" crossorigin="anonymous"></script>'.format(
cdn_url=plotly_cdn_url(), js_hash=_generate_sri_hash(get_plotlyjs())
)

directory_script = '<script charset="utf-8" src="plotly.min.js"></script>'
Expand Down
41 changes: 41 additions & 0 deletions tests/test_io/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import pytest
import numpy as np
import re


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):
Expand Down Expand Up @@ -46,3 +49,41 @@ 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
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
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}"
10 changes: 9 additions & 1 deletion tests/test_io/test_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || {};"
Expand Down