Skip to content

Commit 1ec864b

Browse files
authored
Merge pull request #5165 from ddworken/add-sri-hash-to-cdn
Add SRI (Subresource Integrity) hash to CDN script tags
2 parents 9dc08a1 + 7a99cd7 commit 1ec864b

File tree

5 files changed

+75
-5
lines changed

5 files changed

+75
-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: 18 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 = """\
@@ -252,11 +262,17 @@ def to_html(
252262
load_plotlyjs = ""
253263

254264
if include_plotlyjs == "cdn":
265+
# Generate SRI hash from the bundled plotly.js content
266+
plotlyjs_content = get_plotlyjs()
267+
sri_hash = _generate_sri_hash(plotlyjs_content)
268+
255269
load_plotlyjs = """\
256270
{win_config}
257-
<script charset="utf-8" src="{cdn_url}"></script>\
271+
<script charset="utf-8" src="{cdn_url}" integrity="{integrity}" crossorigin="anonymous"></script>\
258272
""".format(
259-
win_config=_window_plotly_config, cdn_url=plotly_cdn_url()
273+
win_config=_window_plotly_config,
274+
cdn_url=plotly_cdn_url(),
275+
integrity=sri_hash,
260276
)
261277

262278
elif include_plotlyjs == "directory":

tests/test_core/test_offline/test_offline.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import pytest
1010

1111
import plotly
12+
from plotly.offline import get_plotlyjs
1213
import plotly.io as pio
1314
from plotly.io._utils import plotly_cdn_url
15+
from plotly.io._html import _generate_sri_hash
1416

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

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

4446
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
@@ -2,11 +2,14 @@
22

33
import pytest
44
import numpy as np
5+
import re
56

67

78
import plotly.graph_objs as go
89
import plotly.io as pio
910
from plotly.io._utils import plotly_cdn_url
11+
from plotly.offline.offline import get_plotlyjs
12+
from plotly.io._html import _generate_sri_hash
1013

1114

1215
if sys.version_info >= (3, 3):
@@ -46,3 +49,41 @@ def test_html_deterministic(fig1):
4649
assert pio.to_html(fig1, include_plotlyjs="cdn", div_id=div_id) == pio.to_html(
4750
fig1, include_plotlyjs="cdn", div_id=div_id
4851
)
52+
53+
54+
def test_cdn_includes_integrity_attribute(fig1):
55+
"""Test that the CDN script tag includes an integrity attribute with SHA256 hash"""
56+
html_output = pio.to_html(fig1, include_plotlyjs="cdn")
57+
58+
# Check that the script tag includes integrity attribute
59+
assert 'integrity="sha256-' in html_output
60+
assert 'crossorigin="anonymous"' in html_output
61+
62+
# Verify it's in the correct script tag
63+
cdn_pattern = re.compile(
64+
r'<script[^>]*src="'
65+
+ re.escape(plotly_cdn_url())
66+
+ r'"[^>]*integrity="sha256-[A-Za-z0-9+/=]+"[^>]*>'
67+
)
68+
match = cdn_pattern.search(html_output)
69+
assert match is not None, "CDN script tag with integrity attribute not found"
70+
71+
72+
def test_cdn_integrity_hash_matches_bundled_content(fig1):
73+
"""Test that the SRI hash in CDN script tag matches the bundled plotly.js content"""
74+
html_output = pio.to_html(fig1, include_plotlyjs="cdn")
75+
76+
# Extract the integrity hash from the HTML output
77+
integrity_pattern = re.compile(r'integrity="(sha256-[A-Za-z0-9+/=]+)"')
78+
match = integrity_pattern.search(html_output)
79+
assert match is not None, "Integrity attribute not found"
80+
extracted_hash = match.group(1)
81+
82+
# Generate expected hash from bundled content
83+
plotlyjs_content = get_plotlyjs()
84+
expected_hash = _generate_sri_hash(plotlyjs_content)
85+
86+
# Verify they match
87+
assert (
88+
extracted_hash == expected_hash
89+
), 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
@@ -13,6 +13,7 @@
1313
import plotly.io as pio
1414
from plotly.offline import get_plotlyjs
1515
from plotly.io._utils import plotly_cdn_url
16+
from plotly.io._html import _generate_sri_hash
1617

1718
if sys.version_info >= (3, 3):
1819
import unittest.mock as mock
@@ -298,12 +299,19 @@ def test_repr_html(renderer):
298299
# id number of figure
299300
id_html = str_html.split('document.getElementById("')[1].split('")')[0]
300301
id_pattern = "cd462b94-79ce-42a2-887f-2650a761a144"
302+
303+
# Calculate the SRI hash dynamically
304+
plotlyjs_content = get_plotlyjs()
305+
sri_hash = _generate_sri_hash(plotlyjs_content)
306+
301307
template = (
302308
'<div> <script type="text/javascript">'
303309
"window.PlotlyConfig = {MathJaxConfig: 'local'};</script>\n "
304310
'<script charset="utf-8" src="'
305311
+ plotly_cdn_url()
306-
+ '"></script> '
312+
+ '" integrity="'
313+
+ sri_hash
314+
+ '" crossorigin="anonymous"></script> '
307315
'<div id="cd462b94-79ce-42a2-887f-2650a761a144" class="plotly-graph-div" '
308316
'style="height:100%; width:100%;"></div> <script type="text/javascript">'
309317
" window.PLOTLYENV=window.PLOTLYENV || {};"

0 commit comments

Comments
 (0)