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