Repository: bokeh/bokeh

Issue:
Using Subresource Integrity with CDN URLs
Would be nice for all CDN URLs provided by Bokeh in HTML documents to use [Subresource Integrity (SRI)]( https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity ). At this point basically [all browsers support SRI]( https://caniuse.com/#feat=subresource-integrity ) with the exception of IE (though Edge supports it). This would ensure that browsers checksum content from these CDN URLs and only use content if the checksums match.


Discussion:
Basically there are two tasks here as I see it.

1. Generate hashes for all files used from CDNs (probably at build time) and store them.
2. Inject these hashes into generated HTML documents.
Seems that one gets the following [CORS error]( https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSMissingAllowOrigin ) when doing this with the PyData CDN. However if one uses CDNJS instead, this works without issues. So maybe some tweaking to how things are setup on the PyData CDN will be needed as well.
@jakirkham 

> with the exception of IE 

By this do you mean that they are harmlessly ignored by IE? Or that adding them would cause Bokeh plots on IE to cease to function?

> Generate hashes for all files used from CDNs (probably at build time) and store them.

Store them where, exactly? I assume you mean the hashes need to be bundled with the package somehow, to support step 2), but want to be sure. 

> Inject these hashes into generated HTML documents.

I think there are more questions to answer. How does all this intersect with dev/rc builds, or with "setup.py develop" installs. I guess my preference would be that this only gets used for full releases but since it seems like things would be overly complicated otherwise. 

> So maybe some tweaking to how things are setup on the PyData CDN will be needed as well.

cc @aterrel do you have any ideas here? I don't currently have any visibility into the CDN
I'm not sure how IE would handle them if they showed up. Only putting into words what the chart shows me. If there is an IE user that could test this, that would be really informative.

Yes, by store I mean write them to files included in the package.

Not sure I follow. Could you please elaborate more on your concerns w.r.t. dev/rc builds?
FWIW here's a [plot that uses SRI]( http://www.dudmanlab.org/html/svoboda_sofroniew_forJohn_00_proj.html ). Am able to use this on Firefox, Chrome, and Safari on my Mac. 
I've reached out to some folks at NF who actually manage the CDN who may know more. Tho again we will need to make sure there is no issues with IE first and foremost. 
Thanks Bryan.

If you are someone you know has access to IE, it should be straightforward for them to try that example linked from my previous comment to see if that works. Would be curious to learn the result.
> If you are someone you know has access to IE, it should be straightforward for them to try that example linked from my previous comment to see if that works. Would be curious to learn the result.

I don't, that's why I keep mentioning it. 
Hmm...would have thought that was part of your release testing. How do you make sure releases work on IE now?
> Hmm...would have thought that was part of your release testing. How do you make sure releases work on IE now?

You are vastly overestimating the time and resources we have available. For micro releases we just make sure the appveyor test pass. Only for larger or more risky releases do we do manual windows testing. I have appealed now on multiple occasions asking for someone from the community to take on the role of Windows Maintainer but so far no one has decided to step up. 

Edit: I suppose I should clarify this:

> I don't, that's why I keep mentioning it.

I do, but it's a huge ordeal for me. Do I have a VM handy? Does it still work? Is everything installed? By contrast a regular windows user could check this by clicking a link Given the choice between putting myself through a lot of grunt work, or waiting for a better equipped person to come along who cares enough to put in 60 seconds time, I choose the latter. 
Internet Explorer shouldn't be an issue with SRI. Browsers that don't support SRI still load the relevant scripts - see https://www.troyhunt.com/protecting-your-embedded-content-with-subresource-integrity-sri/:

> Thirdly and finally, if the browser doesn't support SRI... nothing happens. I mean nothing *different* happens in that the browser still loads the script, it just simply ignores the integrity tag.
@martey thanks for the information, I think given that knowledge, we can at least plan to proceed. 

I don't have access to the PyData CDN though, so some coordination will be involved. @aterrel have you accepted the invite that was sent out to add you as an admin?

@jakirkham just to be clear about this plan:

>Basically there are two tasks here as I see it.
> 
> Generate hashes for all files used from CDNs (probably at build time) and store them.
> Inject these hashes into generated HTML documents. 

you mean store them in the distributed packages that are published to pypi and conda? 
@martey are you NF martey? I didn't not know :)
tentatively moving to `short term` but there is some infrastructure work that has to be done first. 
Thanks @martey!

>> Generate hashes for all files used from CDNs (probably at build time) and store them.
>
> you mean store them in the distributed packages that are published to pypi and conda?

That's right, @bryevdv. Admittedly Conda will probably piggy-back on whatever is done for PyPI. Does that seem reasonable to you?
> Does that seem reasonable to you? 

I suppose? I have to be honest and state that I am in the middle of switching jobs and some other life changes at the moment. I'm probably not going to be able to personally do any work on this any time soon. I am happy to help anyone who does want to work on it, give pointers into our codebase, review a PR, help facilitate any related infrastructure work, etc. 
@jakirkham @martey OK an update and a question. I am in the process of setting up *cdn.bokeh.org* to replace *cdn.pydata.org* I plan to cut over to that URL for Bokeh 2.0 (we will have to make sure the old links work basically forever, though) I am planning to use an s3 bucket and CloudFront. I have already set this up, in fact, e.g 

https://cdn.bokeh.org/bokeh/release/bokeh-1.2.0.js

Looking at the kinds of metadata that can be added to S3 obejcts, it does not appear that `Content-Security-Policy` is one of the available keys. At least, the AWS Console UI does not offer it in the list of metadata keys. Maybe a boto upload can set arbitrary headers? Do either of you know?

Alternatively, if CSP cannot be set on the server side, would it still be useful to the `integrity` attribute in `script` tags? (i.e. users would still benefit from digest comparision, even if the server side was not configured with CSP?)

Comments welcome, this is not an area of expertise for me. 
My experience with CloudFront is limited, but the references I could find online all suggest [using Lambda@Edge to add custom headers like `Content-Security-Policy`](https://aws.amazon.com/blogs/networking-and-content-delivery/adding-http-security-headers-using-lambdaedge-and-amazon-cloudfront/). You could also use a [meta tag](https://developers.google.com/web/fundamentals/security/csp/#the_meta_tag).

> Alternatively, if CSP cannot be set on the server side, would it still be useful to the integrity attribute in script tags? (i.e. users would still benefit from digest comparision, even if the server side was not configured with CSP?)

As far as I know, CSP is independent of SRI, so the latter should still work on websites not using the former.

---

This question might betray my complete lack of knowledge of how Bokeh and its CDN work, but *do you need to set a content security policy*?  I think that as long as the CDN supports [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), using JavaScript snippets with SRI hashes should work.

This obviously wouldn't solve the problem of having Bokeh automatically add in SRI hashes when it creates JavaScript tags, but it would help people who manually add in hashes avoid the CORS error mentioned in https://github.com/bokeh/bokeh/issues/8397#issuecomment-436118730. 

> As far as I know, CSP is independent of SRI, so the latter should still work on websites not using the former.

My understanding is that setting CSP means that the CDN would refuse to load except from script tags that provide an SRI to check. Which seemed like a good idea at first but now that I have though about it, does not seem like such a good idea. I am working nearly in the dark here (which is actually a large part of my reticence to act @jakirkham)  I'm working on updating our release automation to send files to the CloudFront CDN in parallel to the current CDN. Once that is done I can look into generating the hashes and baking them in to a build...
Given the recent sale of the `.org` name registry to a private equity firm, I have decided to prioritize this, in case our control of the `cdn.bokeh.org` domain is ever put in jeopardy. Here is my immediate plan, I would appreciate feedback @martey @jakirkham  @mattpap 

* [x] Enable appropriate CORS headers for the CDN. Here is the policy I added to the CDN bucket
    ```
    <?xml version="1.0" encoding="UTF-8"?>
    <CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>GET</AllowedMethod>
    </CORSRule>
    </CORSConfiguration>
    ```
    Would really love to know that this is the best/most appropriate policy (I think it is)
* [x]  Generate hashes for all existing full release assets, using instructions https://www.srihash.org/

   Here is GIST with all the hashes: 

    https://gist.github.com/bryevdv/2da8f238b1a7e4d38a63bee6ad084b1a  

   We probably can't update older Bokeh packages to insert these automatically but publishing them means anyone using an old version could construct their own `script` tags to use them.
* [x] Create an `sri.json` file at the top level of the package that maps *[version][filename]* to a hash:
    ```
    {
        "1.4.0": [
            "bokeh.js": "<hash>",
            "bokeh.min.js": "<hash>"
            ...
        ],
        ...
    }
    ```
    The `sri.json` file is now also on the gist:

    https://gist.github.com/bryevdv/2da8f238b1a7e4d38a63bee6ad084b1a#file-sri-json
* [x] Create a sphinx plugin to publish this information in a tabular form on a dedicated page in the docs
* [ ] Update the codepaths that generate `<script>` tags to look up and include an SRI hash for full release versions (raise an error if not? make this configurable?)

Here is the tricky part: where does the automation update and check in this file? The ideal place would be in `deploy.py` because that's less risky to test/develop and offers a change to abort a release if there are ever issues. But then we have to generate hashes on someone's *local* BokehJS, not on what the CI actually builds and uploads. Doing it in CI means pushing a commit from CI, lot's of chances for things to go wrong there. Also that commit would be *after* the release tag, as thing stand. 

I have another issue that proposes to split out building all the release assets from actually publishing/releasing, by uploading them to some staging area. I think that will be a necessary first step to finishing this. Then the final "x.y.z" can be on a repo state that includes an updated `sri.json`. 
> But then we have to generate hashes on someone's local BokehJS, not on what the CI actually builds and uploads.

The files' content (and their hashes) shouldn't change depending on where they are generated, so you could generate hashes locally and use an automated test on the CI system to confirm that the hashes in `sri.json` are correct.
This is actually a good opportunity to make sure that bundles are all the same on all platforms.
@jakirkham I have reconfigured `cdn.bokeh.org` to forward the the necessary Origin headers for the CORS policy. I was able to get your HTML file working with links to Bokeh 1.0.2 from the CDN (and it failed to work if I changed the version but not the hash). If you could also test things out in your own way to make sure there is not anything I've missed it would be very appreciated.  I should have a final SRI PR this weekend to close this issue but inserting the `integrity` attribute any time CDN resources are used. 

EDIT: FYI you can find the hash manifest for previous releases (currently up to latest 1.4) here 

https://github.com/bokeh/bokeh/blob/master/bokeh/_sri.json
That’s awesome! 🎉 Thanks Bryan! 😀 I’ll take a look and get back to you 🙂

Resolving patch:
diff --git a/bokeh/embed/bundle.py b/bokeh/embed/bundle.py
--- a/bokeh/embed/bundle.py
+++ b/bokeh/embed/bundle.py
@@ -72,21 +72,22 @@ def __init__(self, content):
 class Bundle(object):
 
     @classmethod
-    def of(cls, js_files, js_raw, css_files, css_raw):
-        return cls(js_files=js_files, js_raw=js_raw, css_files=css_files, css_raw=css_raw)
+    def of(cls, js_files, js_raw, css_files, css_raw, hashes):
+        return cls(js_files=js_files, js_raw=js_raw, css_files=css_files, css_raw=css_raw, hashes=hashes)
 
     def __init__(self, **kwargs):
         self.js_files = kwargs.get("js_files", [])
         self.js_raw = kwargs.get("js_raw", [])
         self.css_files = kwargs.get("css_files", [])
         self.css_raw = kwargs.get("css_raw", [])
+        self.hashes = kwargs.get("hashes", None)
 
     def __iter__(self):
         yield self._render_js()
         yield self._render_css()
 
     def _render_js(self):
-        return JS_RESOURCES.render(js_files=self.js_files, js_raw=self.js_raw)
+        return JS_RESOURCES.render(js_files=self.js_files, js_raw=self.js_raw, hashes=self.hashes)
 
     def _render_css(self):
         return CSS_RESOURCES.render(css_files=self.css_files, css_raw=self.css_raw)
@@ -182,7 +183,7 @@ def bundle_for_objs_and_resources(objs, resources):
     if ext is not None:
         js_raw.append(ext)
 
-    return Bundle.of(js_files, js_raw, css_files, css_raw)
+    return Bundle.of(js_files, js_raw, css_files, css_raw, js_resources.hashes if js_resources else None)
 
 #-----------------------------------------------------------------------------
 # Private API
diff --git a/bokeh/resources.py b/bokeh/resources.py
--- a/bokeh/resources.py
+++ b/bokeh/resources.py
@@ -343,6 +343,7 @@ def _server_urls(self):
     def _resolve(self, kind):
         paths = self._file_paths(kind)
         files, raw = [], []
+        hashes = None
 
         if self.mode == "inline":
             raw = [self._inline(path) for path in paths]
@@ -354,11 +355,13 @@ def _resolve(self, kind):
         elif self.mode == "cdn":
             cdn = self._cdn_urls()
             files = list(cdn["urls"](self.components(kind), kind))
+            if cdn["hashes"]:
+                hashes = cdn["hashes"](self.components(kind), kind)
         elif self.mode == "server":
             server = self._server_urls()
             files = list(server["urls"](self.components(kind), kind))
 
-        return (files, raw)
+        return (files, raw, hashes)
 
     @staticmethod
     def _inline(path):
@@ -426,13 +429,13 @@ class JSResources(BaseResources):
 
     @property
     def js_files(self):
-        files, _ = self._resolve("js")
+        files, _, __ = self._resolve("js")
         external_resources = self._collect_external_resources("__javascript__")
         return external_resources + files
 
     @property
     def js_raw(self):
-        _, raw = self._resolve("js")
+        _, raw, __ = self._resolve("js")
 
         if self.log_level is not None:
             raw.append('Bokeh.set_log_level("%s");' % self.log_level)
@@ -442,10 +445,15 @@ def js_raw(self):
 
         return raw
 
+    @property
+    def hashes(self):
+        _, __, hashes = self._resolve("js")
+        return hashes
+
     # Public methods ----------------------------------------------------------
 
     def render_js(self):
-        return JS_RESOURCES.render(js_raw=self.js_raw, js_files=self.js_files)
+        return JS_RESOURCES.render(js_raw=self.js_raw, js_files=self.js_files, hashes=self.hashes)
 
 
 class CSSResources(BaseResources):
@@ -498,13 +506,13 @@ class CSSResources(BaseResources):
 
     @property
     def css_files(self):
-        files, _ = self._resolve("css")
+        files, _, __ = self._resolve("css")
         external_resources = self._collect_external_resources("__css__")
         return external_resources + files
 
     @property
     def css_raw(self):
-        _, raw = self._resolve("css")
+        _, raw, __ = self._resolve("css")
         return raw
 
     @property
@@ -644,15 +652,20 @@ def _get_cdn_urls(version=None, minified=True, legacy=False):
     # check the 'dev' fingerprint
     container = dev_container if _DEV_PAT.match(version) else rel_container
 
-    if version.endswith(("dev", "rc")):
-        log.debug("Getting CDN URL for local dev version will not produce usable URL")
+    def mk_filename(comp, kind):
+        return f"{comp}-{version}{_minified}.{kind}"
 
     def mk_url(comp, kind):
-        return f"{base_url}/{container}/{_legacy}{comp}-{version}{_minified}.{kind}"
+        return f"{base_url}/{container}/{_legacy}" + mk_filename(comp, kind)
 
-    result = {"urls": lambda components, kind: [mk_url(component, kind) for component in components], "messages": []}
+    result = {
+        "urls": lambda components, kind: [mk_url(component, kind) for component in components],
+        "messages": [],
+        "hashes" : None
+    }
 
     if len(__version__.split("-")) > 1:
+        log.debug("Getting CDN URL for local dev version may not produce usable URL")
         result["messages"].append(
             {
                 "type": "warn",
@@ -663,6 +676,10 @@ def mk_url(comp, kind):
             }
         )
 
+    if is_full_release():
+        sri_hashes = get_sri_hashes_for_version(version)
+        result['hashes'] = lambda components, kind: {mk_url(component, kind): sri_hashes[mk_filename(component, kind)] for component in components}
+
     return result
 
 


Test patch:
diff --git a/tests/unit/bokeh/test_resources.py b/tests/unit/bokeh/test_resources.py
--- a/tests/unit/bokeh/test_resources.py
+++ b/tests/unit/bokeh/test_resources.py
@@ -22,6 +22,7 @@
 from packaging.version import Version as V
 
 # Bokeh imports
+import bokeh.util.version as buv
 from bokeh import __version__
 from bokeh.models import Model
 from bokeh.resources import _get_cdn_urls
@@ -103,6 +104,22 @@ def test_js_resources_inline_has_no_css_resources() -> None:
     assert hasattr(r, "css_raw") is False
     assert r.messages == []
 
+def test_js_resources_hashes_mock_full(monkeypatch) -> None:
+    monkeypatch.setattr(buv, "__version__", "1.4.0")
+    monkeypatch.setattr(resources, "__version__", "1.4.0")
+    r = resources.JSResources()
+    assert r.mode == "cdn"
+    min_hashes = set(v for k, v in resources.get_sri_hashes_for_version("1.4.0").items() if k.endswith(".min.js") and "api" not in k)
+    assert set(r.hashes.values()) == min_hashes
+
+@pytest.mark.parametrize('v', ["1.4.0dev6", "1.4.0rc1", "1.4.0dev6-50-foo"])
+@pytest.mark.unit
+def test_js_resources_hashes_mock_non_full(v, monkeypatch) -> None:
+    monkeypatch.setattr(buv, "__version__", v)
+    monkeypatch.setattr(resources, "__version__", v)
+    r = resources.JSResources()
+    assert r.mode == "cdn"
+    assert r.hashes == None
 
 ## Test CSSResources