Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions pyinstrument/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
16 changes: 14 additions & 2 deletions pyinstrument/renderers/html.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import codecs
import html
import json
import sys
import tempfile
Expand Down Expand Up @@ -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,
*,
Expand All @@ -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
Expand Down Expand Up @@ -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"""<!DOCTYPE html>
<html>
Expand All @@ -115,10 +127,10 @@ def render(self, session: Session):
<body>
<div id="app"></div>

<script>{js}</script>
<script{nonce_attribute}>{js}</script>
<style>{css}</style>

<script>
<script{nonce_attribute}>
const sessionData = {session_json};
pyinstrumentHTMLRenderer.render(document.getElementById('app'), sessionData);
</script>
Expand Down
26 changes: 26 additions & 0 deletions test/test_django_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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('<script nonce="nonce&amp;&quot;value">') == 2