-
Notifications
You must be signed in to change notification settings - Fork 1
fix: ensure security headers on all HTTP responses #437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
36c7d59
3f64a2e
61c4922
c68a656
8438493
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,15 +1,24 @@ | ||||||||||||||||||||||||||||||||||||||||||
| """Request middleware. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Provides ASGI middleware for request logging and path-aware | ||||||||||||||||||||||||||||||||||||||||||
| Content-Security-Policy headers. | ||||||||||||||||||||||||||||||||||||||||||
| """Request middleware and before-send hooks. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Provides ASGI middleware for request logging, and a ``before_send`` | ||||||||||||||||||||||||||||||||||||||||||
| hook that injects security headers (CSP, CORP, HSTS, etc.) into | ||||||||||||||||||||||||||||||||||||||||||
| **every** HTTP response — including exception-handler and | ||||||||||||||||||||||||||||||||||||||||||
| unmatched-route (404/405) responses. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Why ``before_send`` instead of ASGI middleware? | ||||||||||||||||||||||||||||||||||||||||||
| Litestar's ``before_send`` hook wraps the ASGI ``send`` callback at | ||||||||||||||||||||||||||||||||||||||||||
| the outermost layer (before the middleware stack), so it fires for | ||||||||||||||||||||||||||||||||||||||||||
| all responses. By contrast, user-defined ASGI middleware only runs | ||||||||||||||||||||||||||||||||||||||||||
| for matched routes — 404 and 405 responses from the router bypass it. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||
| from typing import Any, Final | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| from litestar import Request | ||||||||||||||||||||||||||||||||||||||||||
| from litestar.datastructures import MutableScopeHeaders | ||||||||||||||||||||||||||||||||||||||||||
| from litestar.enums import ScopeType | ||||||||||||||||||||||||||||||||||||||||||
| from litestar.types import ASGIApp, Receive, Scope, Send # noqa: TC002 | ||||||||||||||||||||||||||||||||||||||||||
| from litestar.types import ASGIApp, Message, Receive, Scope, Send # noqa: TC002 | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| from synthorg.observability import get_logger | ||||||||||||||||||||||||||||||||||||||||||
| from synthorg.observability.events.api import ( | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -19,63 +28,85 @@ | |||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| logger = get_logger(__name__) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # ── Security headers ──────────────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||||
| # Applied to every HTTP response via the before_send hook. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Strict CSP for API routes — no inline scripts, self-origin only. | ||||||||||||||||||||||||||||||||||||||||||
| _API_CSP: Final[str] = "default-src 'self'; script-src 'self'" | ||||||||||||||||||||||||||||||||||||||||||
| _API_CSP: Final[str] = ( | ||||||||||||||||||||||||||||||||||||||||||
| "default-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'" | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Relaxed CSP for /docs/ — Scalar UI loads resources from external origins. | ||||||||||||||||||||||||||||||||||||||||||
| # cdn.jsdelivr.net: JS bundle, CSS, fonts, source maps | ||||||||||||||||||||||||||||||||||||||||||
| # fonts.scalar.com: Scalar-hosted font files | ||||||||||||||||||||||||||||||||||||||||||
| # proxy.scalar.com: API proxy and registry features | ||||||||||||||||||||||||||||||||||||||||||
| # 'unsafe-inline' in script-src/style-src: required by Scalar UI which uses | ||||||||||||||||||||||||||||||||||||||||||
| # inline <script> and <style> elements. Accepted risk — /docs is read-only, | ||||||||||||||||||||||||||||||||||||||||||
| # unauthenticated, and serves no user-submitted content. | ||||||||||||||||||||||||||||||||||||||||||
| _DOCS_CSP: Final[str] = ( | ||||||||||||||||||||||||||||||||||||||||||
| "default-src 'self'; " | ||||||||||||||||||||||||||||||||||||||||||
| "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | ||||||||||||||||||||||||||||||||||||||||||
| "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " | ||||||||||||||||||||||||||||||||||||||||||
| "img-src 'self' data: https://cdn.jsdelivr.net; " | ||||||||||||||||||||||||||||||||||||||||||
| "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com; " | ||||||||||||||||||||||||||||||||||||||||||
| "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com" | ||||||||||||||||||||||||||||||||||||||||||
| "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com; " | ||||||||||||||||||||||||||||||||||||||||||
| "base-uri 'self'; " | ||||||||||||||||||||||||||||||||||||||||||
| "frame-ancestors 'none'" | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
48
to
58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the API CSP, the CSP for the documentation pages is missing the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| class CSPMiddleware: | ||||||||||||||||||||||||||||||||||||||||||
| """ASGI middleware that applies path-aware Content-Security-Policy. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| API routes get a strict policy (self-origin only). The ``/docs/`` | ||||||||||||||||||||||||||||||||||||||||||
| path gets a relaxed policy that allows Scalar UI resources from | ||||||||||||||||||||||||||||||||||||||||||
| ``cdn.jsdelivr.net``, ``fonts.scalar.com``, and | ||||||||||||||||||||||||||||||||||||||||||
| ``proxy.scalar.com``. | ||||||||||||||||||||||||||||||||||||||||||
| # Static security headers (path-independent). | ||||||||||||||||||||||||||||||||||||||||||
| _SECURITY_HEADERS: Final[dict[str, str]] = { | ||||||||||||||||||||||||||||||||||||||||||
| "X-Content-Type-Options": "nosniff", | ||||||||||||||||||||||||||||||||||||||||||
| "X-Frame-Options": "DENY", | ||||||||||||||||||||||||||||||||||||||||||
| "Referrer-Policy": "strict-origin-when-cross-origin", | ||||||||||||||||||||||||||||||||||||||||||
| "Strict-Transport-Security": "max-age=63072000; includeSubDomains", | ||||||||||||||||||||||||||||||||||||||||||
| "Permissions-Policy": "geolocation=(), camera=(), microphone=()", | ||||||||||||||||||||||||||||||||||||||||||
| "Cross-Origin-Resource-Policy": "same-origin", | ||||||||||||||||||||||||||||||||||||||||||
| "Cross-Origin-Opener-Policy": "same-origin", | ||||||||||||||||||||||||||||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||
| "Cache-Control": "no-store", | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| async def security_headers_hook(message: Message, scope: Scope) -> None: | ||||||||||||||||||||||||||||||||||||||||||
| """Inject security headers into every HTTP response. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Registered as a Litestar ``before_send`` hook so it fires for | ||||||||||||||||||||||||||||||||||||||||||
| **all** HTTP responses — successful, exception-handler, and | ||||||||||||||||||||||||||||||||||||||||||
| router-level 404/405. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Adds static security headers (CORP, HSTS, X-Content-Type-Options, | ||||||||||||||||||||||||||||||||||||||||||
| etc.) and a path-aware Content-Security-Policy (strict for API, | ||||||||||||||||||||||||||||||||||||||||||
| relaxed for ``/docs/`` to allow Scalar UI resources). | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Uses ``__setitem__`` (not ``add``) so that if any handler or | ||||||||||||||||||||||||||||||||||||||||||
| middleware already set a header, the known-good value overwrites | ||||||||||||||||||||||||||||||||||||||||||
| it rather than creating a duplicate. | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||
| message: ASGI message dict (only ``http.response.start`` | ||||||||||||||||||||||||||||||||||||||||||
| is processed). | ||||||||||||||||||||||||||||||||||||||||||
| scope: ASGI connection scope. | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def __init__(self, app: ASGIApp) -> None: | ||||||||||||||||||||||||||||||||||||||||||
| self.app = app | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| async def __call__( | ||||||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||||||
| scope: Scope, | ||||||||||||||||||||||||||||||||||||||||||
| receive: Receive, | ||||||||||||||||||||||||||||||||||||||||||
| send: Send, | ||||||||||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||||||||||
| """Inject the appropriate CSP header based on request path.""" | ||||||||||||||||||||||||||||||||||||||||||
| if scope["type"] != ScopeType.HTTP: | ||||||||||||||||||||||||||||||||||||||||||
| await self.app(scope, receive, send) | ||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| path: str = scope.get("path", "") | ||||||||||||||||||||||||||||||||||||||||||
| is_docs = path == "/docs" or path.startswith("/docs/") | ||||||||||||||||||||||||||||||||||||||||||
| csp_value = _DOCS_CSP if is_docs else _API_CSP | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| async def inject_csp(message: Any) -> None: | ||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||
| isinstance(message, dict) | ||||||||||||||||||||||||||||||||||||||||||
| and message.get("type") == "http.response.start" | ||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||
| headers = list(message.get("headers", [])) | ||||||||||||||||||||||||||||||||||||||||||
| headers.append( | ||||||||||||||||||||||||||||||||||||||||||
| (b"content-security-policy", csp_value.encode()), | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| message = {**message, "headers": headers} | ||||||||||||||||||||||||||||||||||||||||||
| await send(message) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| await self.app(scope, receive, inject_csp) | ||||||||||||||||||||||||||||||||||||||||||
| if scope.get("type") != ScopeType.HTTP: | ||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||
| if message.get("type") != "http.response.start": | ||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| headers = MutableScopeHeaders.from_message(message) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Static security headers — overwrite to prevent duplicates. | ||||||||||||||||||||||||||||||||||||||||||
| for name, value in _SECURITY_HEADERS.items(): | ||||||||||||||||||||||||||||||||||||||||||
| headers[name] = value | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Path-aware headers | ||||||||||||||||||||||||||||||||||||||||||
| path: str = scope.get("path", "") | ||||||||||||||||||||||||||||||||||||||||||
| is_docs = path == "/docs" or path.startswith("/docs/") | ||||||||||||||||||||||||||||||||||||||||||
| headers["Content-Security-Policy"] = _DOCS_CSP if is_docs else _API_CSP | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| # Relax COOP for /docs — Scalar UI may use cross-origin popups | ||||||||||||||||||||||||||||||||||||||||||
| # for OAuth/API proxy features via proxy.scalar.com. | ||||||||||||||||||||||||||||||||||||||||||
| if is_docs: | ||||||||||||||||||||||||||||||||||||||||||
| headers["Cross-Origin-Opener-Policy"] = "unsafe-none" | ||||||||||||||||||||||||||||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| class RequestLoggingMiddleware: | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.