diff --git a/docs/guide.md b/docs/guide.md index 037637bc..73d13bcd 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -191,6 +191,20 @@ Default value is `pyinstrument.renderers.HTMLRenderer`. The supported renderers You can configure the sampling interval using setting's variable `PYINSTRUMENT_INTERVAL`. Default value is 0.001. +**Use a Content Security Policy nonce** + +If your site's Content Security Policy disallows inline scripts, set +`PYINSTRUMENT_CSP_NONCE` to a callback that returns the nonce for the current +response. Pyinstrument calls the callback once and adds the returned value to +every inline script in the profiling page. + +```python +PYINSTRUMENT_CSP_NONCE = get_current_csp_nonce +``` + +The same nonce must be allowed by the response's `Content-Security-Policy` +header. Pyinstrument does not generate the nonce or modify the policy header. + ## Profile a web request in Flask A simple setup to profile a Flask application is the following: diff --git a/pyinstrument/middleware.py b/pyinstrument/middleware.py index f85bee40..b2d90f5f 100644 --- a/pyinstrument/middleware.py +++ b/pyinstrument/middleware.py @@ -34,6 +34,20 @@ def get_renderer(path) -> Renderer: return HTMLRenderer() +def get_csp_nonce(): + """Return the configured CSP nonce, if any.""" + nonce_callback = getattr(settings, "PYINSTRUMENT_CSP_NONCE", None) + if nonce_callback is None: + return None + if not callable(nonce_callback): + raise ValueError("PYINSTRUMENT_CSP_NONCE should be a callable") + + nonce = nonce_callback() + if not isinstance(nonce, str): + raise ValueError("PYINSTRUMENT_CSP_NONCE callback return value should be a string") + return nonce + + class ProfilerMiddleware(MiddlewareMixin): # type: ignore def process_request(self, request): profile_dir = getattr(settings, "PYINSTRUMENT_PROFILE_DIR", None) @@ -62,6 +76,8 @@ def process_response(self, request, response): configured_renderer = getattr(settings, "PYINSTRUMENT_PROFILE_DIR_RENDERER", None) renderer = get_renderer(configured_renderer) + if isinstance(renderer, HTMLRenderer): + renderer.csp_nonce = get_csp_nonce() output = renderer.render(profile_session) @@ -107,6 +123,7 @@ def process_response(self, request, response): return HttpResponse(output) # type: ignore else: renderer = HTMLRenderer() + renderer.csp_nonce = get_csp_nonce() output = renderer.render(profile_session) return HttpResponse(output) # type: ignore else: diff --git a/pyinstrument/renderers/html.py b/pyinstrument/renderers/html.py index e8926771..ca2afc33 100644 --- a/pyinstrument/renderers/html.py +++ b/pyinstrument/renderers/html.py @@ -1,6 +1,7 @@ from __future__ import annotations import codecs +import html import json import sys import tempfile @@ -37,6 +38,11 @@ class HTMLRenderer(Renderer): Options to pass to the preprocessors, like :attr:`FrameRenderer.processor_options`. """ + csp_nonce: str | None + """ + A nonce to add to inline script elements in the rendered HTML. + """ + def __init__( self, *, @@ -62,6 +68,7 @@ def __init__( ) self.resample_interval = resample_interval + self.csp_nonce = None # These settings are passed down to JSONForHTMLRenderer, and can be # used to modify its output. E.g. they can be used to lower the size @@ -106,6 +113,11 @@ def render(self, session: Session): js = js_file.read_text(encoding="utf-8") css = css_file.read_text(encoding="utf-8") + nonce_attribute = ( + f' nonce="{html.escape(self.csp_nonce, quote=True)}"' + if self.csp_nonce is not None + else "" + ) page = f""" @@ -115,10 +127,10 @@ def render(self, session: Session):
- + - diff --git a/test/test_django_middleware.py b/test/test_django_middleware.py index 3f02850f..acff8b1e 100644 --- a/test/test_django_middleware.py +++ b/test/test_django_middleware.py @@ -3,6 +3,7 @@ django = pytest.importorskip("django") from django.conf import settings # noqa: E402 +from django.http import HttpResponse # noqa: E402 from django.test import RequestFactory # noqa: E402 @@ -52,3 +53,28 @@ def test_profile_dir_still_profiles_when_callback_allows(tmp_path): assert hasattr(request, "profiler") finally: stop_profiler(request) + + +def test_csp_nonce_is_applied_to_profile_scripts(): + from django.test import override_settings + + nonce_calls = 0 + + def get_nonce(): + nonlocal nonce_calls + nonce_calls += 1 + return 'nonce&"value' + + with override_settings(PYINSTRUMENT_CSP_NONCE=get_nonce): + request = RequestFactory().get("/?profile") + middleware = make_middleware() + try: + middleware.process_request(request) + response = middleware.process_response(request, HttpResponse()) + except Exception: + stop_profiler(request) + raise + + output = response.content.decode() + assert nonce_calls == 1 + assert output.count('