Skip to content

Commit 12cd7a6

Browse files
committed
merging recent changes from main
2 parents 4a1a814 + 1ec864b commit 12cd7a6

File tree

5 files changed

+77
-5
lines changed

5 files changed

+77
-5
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
2020
- 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)]
2121
- Reduce package bundle size by 18-24% via changes to code generation [[#4978](https://github.com/plotly/plotly.py/pull/4978)]
2222

23+
### Added
24+
- 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)]
25+
2326
### Fixed
2427
- Fix third-party widget display issues in v6 [[#5102](https://github.com/plotly/plotly.py/pull/5102)]
2528
- Add handling for case where `jupyterlab` or `notebook` is not installed [[#5104](https://github.com/plotly/plotly.py/pull/5104/files)]

plotly/io/_html.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import uuid
22
from pathlib import Path
33
import webbrowser
4+
import hashlib
5+
import base64
46

57
from _plotly_utils.optional_imports import get_module
68
from plotly.io._utils import validate_coerce_fig_to_dict, plotly_cdn_url
@@ -9,6 +11,14 @@
911
_json = get_module("json")
1012

1113

14+
def _generate_sri_hash(content):
15+
"""Generate SHA256 hash for SRI (Subresource Integrity)"""
16+
if isinstance(content, str):
17+
content = content.encode("utf-8")
18+
sha256_hash = hashlib.sha256(content).digest()
19+
return "sha256-" + base64.b64encode(sha256_hash).decode("utf-8")
20+
21+
1222
# Build script to set global PlotlyConfig object. This must execute before
1323
# plotly.js is loaded.
1424
_window_plotly_config = """\
@@ -244,10 +254,18 @@ def to_html(
244254
load_plotlyjs = ""
245255

246256
if include_plotlyjs == "cdn":
257+
# Generate SRI hash from the bundled plotly.js content
258+
plotlyjs_content = get_plotlyjs()
259+
sri_hash = _generate_sri_hash(plotlyjs_content)
260+
247261
load_plotlyjs = """\
248262
{win_config}
249-
<script charset="utf-8" src="{cdn_url}"></script>\
250-
""".format(win_config=_window_plotly_config, cdn_url=plotly_cdn_url())
263+
<script charset="utf-8" src="{cdn_url}" integrity="{integrity}" crossorigin="anonymous"></script>\
264+
""".format(
265+
win_config=_window_plotly_config,
266+
cdn_url=plotly_cdn_url(),
267+
integrity=sri_hash,
268+
)
251269

252270
elif include_plotlyjs == "directory":
253271
load_plotlyjs = """\

tests/test_core/test_offline/test_offline.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from unittest import TestCase
99

1010
import plotly
11+
from plotly.offline import get_plotlyjs
1112
import plotly.io as pio
1213
from plotly.io._utils import plotly_cdn_url
14+
from plotly.io._html import _generate_sri_hash
1315

1416
packages_root = os.path.dirname(
1517
os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(plotly.__file__))))
@@ -36,8 +38,8 @@
3638
<script type="text/javascript">\
3739
window.PlotlyConfig = {MathJaxConfig: 'local'};</script>"""
3840

39-
cdn_script = '<script charset="utf-8" src="{cdn_url}"></script>'.format(
40-
cdn_url=plotly_cdn_url()
41+
cdn_script = '<script charset="utf-8" src="{cdn_url}" integrity="{js_hash}" crossorigin="anonymous"></script>'.format(
42+
cdn_url=plotly_cdn_url(), js_hash=_generate_sri_hash(get_plotlyjs())
4143
)
4244

4345
directory_script = '<script charset="utf-8" src="plotly.min.js"></script>'

tests/test_io/test_html.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import pytest
22
import numpy as np
3+
import re
34

45

56
import plotly.graph_objs as go
67
import plotly.io as pio
78
from plotly.io._utils import plotly_cdn_url
9+
from plotly.offline.offline import get_plotlyjs
10+
from plotly.io._html import _generate_sri_hash
811

912

1013
@pytest.fixture
@@ -30,3 +33,41 @@ def test_html_deterministic(fig1):
3033
assert pio.to_html(fig1, include_plotlyjs="cdn", div_id=div_id) == pio.to_html(
3134
fig1, include_plotlyjs="cdn", div_id=div_id
3235
)
36+
37+
38+
def test_cdn_includes_integrity_attribute(fig1):
39+
"""Test that the CDN script tag includes an integrity attribute with SHA256 hash"""
40+
html_output = pio.to_html(fig1, include_plotlyjs="cdn")
41+
42+
# Check that the script tag includes integrity attribute
43+
assert 'integrity="sha256-' in html_output
44+
assert 'crossorigin="anonymous"' in html_output
45+
46+
# Verify it's in the correct script tag
47+
cdn_pattern = re.compile(
48+
r'<script[^>]*src="'
49+
+ re.escape(plotly_cdn_url())
50+
+ r'"[^>]*integrity="sha256-[A-Za-z0-9+/=]+"[^>]*>'
51+
)
52+
match = cdn_pattern.search(html_output)
53+
assert match is not None, "CDN script tag with integrity attribute not found"
54+
55+
56+
def test_cdn_integrity_hash_matches_bundled_content(fig1):
57+
"""Test that the SRI hash in CDN script tag matches the bundled plotly.js content"""
58+
html_output = pio.to_html(fig1, include_plotlyjs="cdn")
59+
60+
# Extract the integrity hash from the HTML output
61+
integrity_pattern = re.compile(r'integrity="(sha256-[A-Za-z0-9+/=]+)"')
62+
match = integrity_pattern.search(html_output)
63+
assert match is not None, "Integrity attribute not found"
64+
extracted_hash = match.group(1)
65+
66+
# Generate expected hash from bundled content
67+
plotlyjs_content = get_plotlyjs()
68+
expected_hash = _generate_sri_hash(plotlyjs_content)
69+
70+
# Verify they match
71+
assert (
72+
extracted_hash == expected_hash
73+
), f"Hash mismatch: expected {expected_hash}, got {extracted_hash}"

tests/test_io/test_renderers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import plotly.io as pio
1212
from plotly.offline import get_plotlyjs
1313
from plotly.io._utils import plotly_cdn_url
14+
from plotly.io._html import _generate_sri_hash
1415

1516
import unittest.mock as mock
1617
from unittest.mock import MagicMock
@@ -292,12 +293,19 @@ def test_repr_html(renderer):
292293
# id number of figure
293294
id_html = str_html.split('document.getElementById("')[1].split('")')[0]
294295
id_pattern = "cd462b94-79ce-42a2-887f-2650a761a144"
296+
297+
# Calculate the SRI hash dynamically
298+
plotlyjs_content = get_plotlyjs()
299+
sri_hash = _generate_sri_hash(plotlyjs_content)
300+
295301
template = (
296302
'<div> <script type="text/javascript">'
297303
"window.PlotlyConfig = {MathJaxConfig: 'local'};</script>\n "
298304
'<script charset="utf-8" src="'
299305
+ plotly_cdn_url()
300-
+ '"></script> '
306+
+ '" integrity="'
307+
+ sri_hash
308+
+ '" crossorigin="anonymous"></script> '
301309
'<div id="cd462b94-79ce-42a2-887f-2650a761a144" class="plotly-graph-div" '
302310
'style="height:100%; width:100%;"></div> <script type="text/javascript">'
303311
" window.PLOTLYENV=window.PLOTLYENV || {};"

0 commit comments

Comments
 (0)