--- a/docs/source/operators/security.rst +++ b/docs/source/operators/security.rst @@ -453,6 +453,22 @@ These two methods simply load the notebook, compute a new signature, and add that signature to the user's database. +Content-Security-Policy for nbconvert +-------------------------------------- + +When notebooks are rendered via nbconvert (``/nbconvert/`` endpoints), +the server adds a ``sandbox allow-scripts`` directive to the +``Content-Security-Policy`` header by default. This confines any +JavaScript in the rendered output to a unique origin, preventing it +from interacting with the Jupyter server. + +This behavior is controlled by :attr:`~jupyter_server.serverapp.ServerApp.nbconvert_csp_sandbox`: + +.. sourcecode:: python + + # jupyter_server_config.py + c.ServerApp.nbconvert_csp_sandbox = True # default + Reporting security issues ------------------------- --- a/jupyter_server/nbconvert/handlers.py +++ b/jupyter_server/nbconvert/handlers.py @@ -92,6 +92,14 @@ auth_resource = AUTH_RESOURCE SUPPORTED_METHODS = ("GET",) + @property + def content_security_policy(self): + # In case we're serving HTML, confine any Javascript to a unique + # origin so it can't interact with the Jupyter server. + if self.settings.get("nbconvert_csp_sandbox", True): + return super().content_security_policy + "; sandbox allow-scripts" + return super().content_security_policy + @web.authenticated @authorized async def get(self, format, path): @@ -173,6 +181,14 @@ SUPPORTED_METHODS = ("POST",) auth_resource = AUTH_RESOURCE + @property + def content_security_policy(self): + # In case we're serving HTML, confine any Javascript to a unique + # origin so it can't interact with the Jupyter server. + if self.settings.get("nbconvert_csp_sandbox", True): + return super().content_security_policy + "; sandbox allow-scripts" + return super().content_security_policy + @web.authenticated @authorized async def post(self, format): --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -457,6 +457,7 @@ "websocket_ping_timeout": websocket_ping_timeout, # handlers "extra_services": extra_services, + "nbconvert_csp_sandbox": jupyter_app.nbconvert_csp_sandbox, # Jupyter stuff "started": now, # place for extensions to register activity @@ -1606,6 +1607,15 @@ help="""If True, display controls to shut down the Jupyter server, such as menu items or buttons.""", ) + nbconvert_csp_sandbox = Bool( + True, + config=True, + help=_i18n( + "If True, add a 'sandbox' directive to the Content-Security-Policy header for nbconvert-served pages, " + "confining any JavaScript to a unique origin so it cannot interact with the Jupyter server." + ), + ) + contents_manager_class = Type( default_value=AsyncLargeFileManager, klass=ContentsManager, --- a/tests/nbconvert/test_handlers.py +++ b/tests/nbconvert/test_handlers.py @@ -190,3 +190,38 @@ r = await jp_fetch("nbconvert", "latex", method="POST", body=json.dumps(nbmodel)) assert "application/zip" in r.headers["Content-Type"] assert ".zip" in r.headers["Content-Disposition"] + + +@pytest.mark.parametrize( + "jp_server_config,expected", + [ + ({"ServerApp": {"nbconvert_csp_sandbox": True}}, "sandbox allow-scripts"), + ({"ServerApp": {"nbconvert_csp_sandbox": False}}, None), + ], +) +async def test_csp_file_handler(jp_fetch, jp_server_config, notebook, expected): + r = await jp_fetch("nbconvert", "html", "foo", "testnb.ipynb", method="GET") + csp = r.headers["Content-Security-Policy"] + if expected: + assert expected in csp + else: + assert "sandbox" not in csp + + +@pytest.mark.parametrize( + "jp_server_config,expected", + [ + ({"ServerApp": {"nbconvert_csp_sandbox": True}}, "sandbox allow-scripts"), + ({"ServerApp": {"nbconvert_csp_sandbox": False}}, None), + ], +) +async def test_csp_post_handler(jp_fetch, jp_server_config, notebook, expected): + r = await jp_fetch("api/contents/foo/testnb.ipynb", method="GET") + nbmodel = json.loads(r.body.decode()) + + r = await jp_fetch("nbconvert", "html", method="POST", body=json.dumps(nbmodel)) + csp = r.headers["Content-Security-Policy"] + if expected: + assert expected in csp + else: + assert "sandbox" not in csp