Skip to content

Commit 6cbee8d

Browse files
authored
Merge commit from fork
* Fix XSS in nbconvert handler using CSP Co-Authored-By: @y011d4 GHSA-fcw5-x6j4-ccmp * Add a config for CSP, enabled by default, and document it * Test new config
1 parent 333e700 commit 6cbee8d

4 files changed

Lines changed: 77 additions & 0 deletions

File tree

docs/source/operators/security.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,22 @@ long time. Users can explicitly trust a notebook in two ways:
554554
These two methods simply load the notebook, compute a new signature, and add
555555
that signature to the user's database.
556556

557+
Content-Security-Policy for nbconvert
558+
--------------------------------------
559+
560+
When notebooks are rendered via nbconvert (``/nbconvert/`` endpoints),
561+
the server adds a ``sandbox allow-scripts`` directive to the
562+
``Content-Security-Policy`` header by default. This confines any
563+
JavaScript in the rendered output to a unique origin, preventing it
564+
from interacting with the Jupyter server.
565+
566+
This behavior is controlled by :attr:`~jupyter_server.serverapp.ServerApp.nbconvert_csp_sandbox`:
567+
568+
.. sourcecode:: python
569+
570+
# jupyter_server_config.py
571+
c.ServerApp.nbconvert_csp_sandbox = True # default
572+
557573
Reporting security issues
558574
-------------------------
559575

jupyter_server/nbconvert/handlers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ class NbconvertFileHandler(JupyterHandler):
9292
auth_resource = AUTH_RESOURCE
9393
SUPPORTED_METHODS = ("GET",)
9494

95+
@property
96+
def content_security_policy(self):
97+
# In case we're serving HTML, confine any Javascript to a unique
98+
# origin so it can't interact with the Jupyter server.
99+
if self.settings.get("nbconvert_csp_sandbox", True):
100+
return super().content_security_policy + "; sandbox allow-scripts"
101+
return super().content_security_policy
102+
95103
@web.authenticated
96104
@authorized
97105
async def get(self, format, path):
@@ -173,6 +181,14 @@ class NbconvertPostHandler(JupyterHandler):
173181
SUPPORTED_METHODS = ("POST",)
174182
auth_resource = AUTH_RESOURCE
175183

184+
@property
185+
def content_security_policy(self):
186+
# In case we're serving HTML, confine any Javascript to a unique
187+
# origin so it can't interact with the Jupyter server.
188+
if self.settings.get("nbconvert_csp_sandbox", True):
189+
return super().content_security_policy + "; sandbox allow-scripts"
190+
return super().content_security_policy
191+
176192
@web.authenticated
177193
@authorized
178194
async def post(self, format):

jupyter_server/serverapp.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ def init_settings(
457457
"websocket_ping_timeout": websocket_ping_timeout,
458458
# handlers
459459
"extra_services": extra_services,
460+
"nbconvert_csp_sandbox": jupyter_app.nbconvert_csp_sandbox,
460461
# Jupyter stuff
461462
"started": now,
462463
# place for extensions to register activity
@@ -1604,6 +1605,15 @@ def template_file_path(self) -> list[str]:
16041605
help="""If True, display controls to shut down the Jupyter server, such as menu items or buttons.""",
16051606
)
16061607

1608+
nbconvert_csp_sandbox = Bool(
1609+
True,
1610+
config=True,
1611+
help=_i18n(
1612+
"If True, add a 'sandbox' directive to the Content-Security-Policy header for nbconvert-served pages, "
1613+
"confining any JavaScript to a unique origin so it cannot interact with the Jupyter server."
1614+
),
1615+
)
1616+
16071617
contents_manager_class = Type(
16081618
default_value=AsyncLargeFileManager,
16091619
klass=ContentsManager,

tests/nbconvert/test_handlers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,38 @@ async def test_from_post_zip(jp_fetch, notebook):
190190
r = await jp_fetch("nbconvert", "latex", method="POST", body=json.dumps(nbmodel))
191191
assert "application/zip" in r.headers["Content-Type"]
192192
assert ".zip" in r.headers["Content-Disposition"]
193+
194+
195+
@pytest.mark.parametrize(
196+
"jp_server_config,expected",
197+
[
198+
({"ServerApp": {"nbconvert_csp_sandbox": True}}, "sandbox allow-scripts"),
199+
({"ServerApp": {"nbconvert_csp_sandbox": False}}, None),
200+
],
201+
)
202+
async def test_csp_file_handler(jp_fetch, jp_server_config, notebook, expected):
203+
r = await jp_fetch("nbconvert", "html", "foo", "testnb.ipynb", method="GET")
204+
csp = r.headers["Content-Security-Policy"]
205+
if expected:
206+
assert expected in csp
207+
else:
208+
assert "sandbox" not in csp
209+
210+
211+
@pytest.mark.parametrize(
212+
"jp_server_config,expected",
213+
[
214+
({"ServerApp": {"nbconvert_csp_sandbox": True}}, "sandbox allow-scripts"),
215+
({"ServerApp": {"nbconvert_csp_sandbox": False}}, None),
216+
],
217+
)
218+
async def test_csp_post_handler(jp_fetch, jp_server_config, notebook, expected):
219+
r = await jp_fetch("api/contents/foo/testnb.ipynb", method="GET")
220+
nbmodel = json.loads(r.body.decode())
221+
222+
r = await jp_fetch("nbconvert", "html", method="POST", body=json.dumps(nbmodel))
223+
csp = r.headers["Content-Security-Policy"]
224+
if expected:
225+
assert expected in csp
226+
else:
227+
assert "sandbox" not in csp

0 commit comments

Comments
 (0)