diff --git a/studio/backend/main.py b/studio/backend/main.py
index 54b0d76286..8627379ee1 100644
--- a/studio/backend/main.py
+++ b/studio/backend/main.py
@@ -249,27 +249,43 @@ from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.requests import Request as _StarletteRequest # noqa: E402
+_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
+
+
+def _build_csp(script_nonce: "str | None" = None) -> str:
+ script_src = "script-src 'self'"
+ if script_nonce:
+ script_src += f" 'nonce-{script_nonce}'"
+ return (
+ "default-src 'self'; "
+ "img-src 'self' data: blob: https://t0.gstatic.com "
+ "https://t1.gstatic.com https://t2.gstatic.com "
+ "https://t3.gstatic.com; "
+ "connect-src 'self'; "
+ "style-src 'self' 'unsafe-inline'; "
+ f"{script_src}; "
+ "font-src 'self' data:; "
+ "frame-ancestors 'none'; "
+ "form-action 'self'; "
+ "base-uri 'self'"
+ )
+
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
- """Set baseline security headers on every response."""
+ """Set baseline security headers on every response.
+
+ The CSP defaults to ``script-src 'self'``. When a downstream handler
+ needs to emit one inline ``"
+ nonce = _secrets.token_urlsafe(16)
+ tag = f''
html = html_bytes.decode("utf-8")
html = html.replace("", f"{tag}", 1)
- return html.encode("utf-8")
+ return html.encode("utf-8"), nonce
def setup_frontend(app: FastAPI, build_path: Path):
@@ -599,17 +715,23 @@ def setup_frontend(app: FastAPI, build_path: Path):
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets")
- @app.get("/")
- async def serve_root():
+ def _build_index_response() -> Response:
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
- content = _inject_bootstrap(content, app)
+ content, nonce = _inject_bootstrap(content, app)
+ headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}
+ if nonce:
+ headers[_CSP_SCRIPT_NONCE_HEADER] = nonce
return Response(
content = content,
media_type = "text/html",
- headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
+ headers = headers,
)
+ @app.get("/")
+ async def serve_root():
+ return _build_index_response()
+
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
if full_path in {"api", "v1"} or full_path.startswith(("api/", "v1/")):
@@ -625,13 +747,6 @@ def setup_frontend(app: FastAPI, build_path: Path):
return FileResponse(file_path)
# Serve index.html as bytes — avoids Content-Length mismatch
- content = (build_path / "index.html").read_bytes()
- content = _strip_crossorigin(content)
- content = _inject_bootstrap(content, app)
- return Response(
- content = content,
- media_type = "text/html",
- headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
- )
+ return _build_index_response()
return True