feat: production-harden app and finalize merge-ready UX/theme baseline
- complete tokenized theme preset system and admin style guide visibility - refine dashboard layout/scroll behavior and shared async/request UI states - add user nav-visibility settings across API, migration, and frontend stores - harden security headers/CSP handling and related test coverage - enforce CI repository hygiene + frontend contract/theme/build quality gates - update docs/env references and make migration smoke test head-aware
This commit is contained in:
parent
ab1d29b203
commit
ae2ca8f149
66 changed files with 5655 additions and 853 deletions
|
|
@ -11,6 +11,32 @@ from starlette.responses import Response
|
|||
from app.logging_context import set_request_id
|
||||
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
DEFAULT_PERMISSIONS_POLICY = (
|
||||
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
|
||||
"geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
|
||||
)
|
||||
DEFAULT_CSP_POLICY = (
|
||||
"default-src 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"object-src 'none'"
|
||||
)
|
||||
DEFAULT_CSP_DOCS_POLICY = (
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self' https:; "
|
||||
"object-src 'none'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -80,6 +106,100 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|||
return any(path.startswith(prefix) for prefix in self._skip_paths)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
x_content_type_options: str = "nosniff",
|
||||
x_frame_options: str = "DENY",
|
||||
referrer_policy: str = "strict-origin-when-cross-origin",
|
||||
permissions_policy: str = DEFAULT_PERMISSIONS_POLICY,
|
||||
cross_origin_opener_policy: str = "same-origin",
|
||||
cross_origin_resource_policy: str = "same-origin",
|
||||
content_security_policy_enabled: bool = True,
|
||||
content_security_policy: str = DEFAULT_CSP_POLICY,
|
||||
content_security_policy_docs: str = DEFAULT_CSP_DOCS_POLICY,
|
||||
content_security_policy_report_only: bool = False,
|
||||
strict_transport_security_enabled: bool = False,
|
||||
strict_transport_security_max_age: int = 31_536_000,
|
||||
strict_transport_security_include_subdomains: bool = True,
|
||||
strict_transport_security_preload: bool = False,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self._enabled = enabled
|
||||
self._x_content_type_options = x_content_type_options.strip()
|
||||
self._x_frame_options = x_frame_options.strip()
|
||||
self._referrer_policy = referrer_policy.strip()
|
||||
self._permissions_policy = permissions_policy.strip()
|
||||
self._cross_origin_opener_policy = cross_origin_opener_policy.strip()
|
||||
self._cross_origin_resource_policy = cross_origin_resource_policy.strip()
|
||||
self._csp_enabled = content_security_policy_enabled
|
||||
self._csp_policy = content_security_policy.strip()
|
||||
self._csp_docs_policy = content_security_policy_docs.strip()
|
||||
self._csp_report_only = content_security_policy_report_only
|
||||
self._hsts_enabled = strict_transport_security_enabled
|
||||
self._hsts_max_age = max(0, strict_transport_security_max_age)
|
||||
self._hsts_include_subdomains = strict_transport_security_include_subdomains
|
||||
self._hsts_preload = strict_transport_security_preload
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
if not self._enabled:
|
||||
return response
|
||||
|
||||
if self._x_content_type_options:
|
||||
response.headers.setdefault("X-Content-Type-Options", self._x_content_type_options)
|
||||
if self._x_frame_options:
|
||||
response.headers.setdefault("X-Frame-Options", self._x_frame_options)
|
||||
if self._referrer_policy:
|
||||
response.headers.setdefault("Referrer-Policy", self._referrer_policy)
|
||||
if self._permissions_policy:
|
||||
response.headers.setdefault("Permissions-Policy", self._permissions_policy)
|
||||
if self._cross_origin_opener_policy:
|
||||
response.headers.setdefault(
|
||||
"Cross-Origin-Opener-Policy",
|
||||
self._cross_origin_opener_policy,
|
||||
)
|
||||
if self._cross_origin_resource_policy:
|
||||
response.headers.setdefault(
|
||||
"Cross-Origin-Resource-Policy",
|
||||
self._cross_origin_resource_policy,
|
||||
)
|
||||
|
||||
csp_policy = self._csp_policy_for_path(request.url.path)
|
||||
if self._csp_enabled and csp_policy:
|
||||
csp_header = (
|
||||
"Content-Security-Policy-Report-Only"
|
||||
if self._csp_report_only
|
||||
else "Content-Security-Policy"
|
||||
)
|
||||
response.headers.setdefault(csp_header, csp_policy)
|
||||
|
||||
hsts = self._strict_transport_security_value()
|
||||
if hsts:
|
||||
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||
|
||||
return response
|
||||
|
||||
def _csp_policy_for_path(self, path: str) -> str:
|
||||
if path.startswith("/docs") or path.startswith("/redoc"):
|
||||
return self._csp_docs_policy or self._csp_policy
|
||||
return self._csp_policy
|
||||
|
||||
def _strict_transport_security_value(self) -> str:
|
||||
if not self._hsts_enabled:
|
||||
return ""
|
||||
|
||||
directives = [f"max-age={self._hsts_max_age}"]
|
||||
if self._hsts_include_subdomains:
|
||||
directives.append("includeSubDomains")
|
||||
if self._hsts_preload:
|
||||
directives.append("preload")
|
||||
return "; ".join(directives)
|
||||
|
||||
|
||||
def parse_skip_paths(raw_value: str) -> tuple[str, ...]:
|
||||
parts = [part.strip() for part in raw_value.split(",")]
|
||||
return tuple(part for part in parts if part)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue