Merge branch 'master' into docs/backend-doc-pass
This commit is contained in:
@@ -49,10 +49,26 @@ class Settings(BaseSettings):
|
||||
|
||||
cors_origins: str = ""
|
||||
base_url: str = ""
|
||||
# Security response headers (blank disables header injection)
|
||||
security_header_x_content_type_options: str = ""
|
||||
security_header_x_frame_options: str = ""
|
||||
security_header_referrer_policy: str = ""
|
||||
security_header_permissions_policy: str = ""
|
||||
|
||||
# Database lifecycle
|
||||
db_auto_migrate: bool = False
|
||||
|
||||
# RQ queueing / dispatch
|
||||
rq_redis_url: str = "redis://localhost:6379/0"
|
||||
rq_queue_name: str = "default"
|
||||
rq_dispatch_throttle_seconds: float = 15.0
|
||||
rq_dispatch_max_retries: int = 3
|
||||
rq_dispatch_retry_base_seconds: float = 10.0
|
||||
rq_dispatch_retry_max_seconds: float = 120.0
|
||||
|
||||
# OpenClaw gateway runtime compatibility
|
||||
gateway_min_version: str = "2026.02.9"
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
log_format: str = "text"
|
||||
|
||||
@@ -224,12 +224,27 @@ def _get_request_id(request: Request) -> str | None:
|
||||
|
||||
|
||||
def _error_payload(*, detail: object, request_id: str | None) -> dict[str, object]:
|
||||
payload: dict[str, Any] = {"detail": detail}
|
||||
payload: dict[str, Any] = {"detail": _json_safe(detail)}
|
||||
if request_id:
|
||||
payload["request_id"] = request_id
|
||||
return payload
|
||||
|
||||
|
||||
def _json_safe(value: object) -> object:
|
||||
"""Return a JSON-serializable representation for error payloads."""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
if isinstance(value, (bytearray, memoryview)):
|
||||
return bytes(value).decode("utf-8", errors="replace")
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(item) for item in value]
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
async def _request_validation_handler(
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
|
||||
81
backend/app/core/security_headers.py
Normal file
81
backend/app/core/security_headers.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""ASGI middleware for configurable security response headers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Inject configured security headers into every HTTP response."""
|
||||
|
||||
_X_CONTENT_TYPE_OPTIONS = b"X-Content-Type-Options"
|
||||
_X_FRAME_OPTIONS = b"X-Frame-Options"
|
||||
_REFERRER_POLICY = b"Referrer-Policy"
|
||||
_PERMISSIONS_POLICY = b"Permissions-Policy"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
*,
|
||||
x_content_type_options: str = "",
|
||||
x_frame_options: str = "",
|
||||
referrer_policy: str = "",
|
||||
permissions_policy: str = "",
|
||||
) -> None:
|
||||
self._app = app
|
||||
self._configured_headers = self._build_configured_headers(
|
||||
x_content_type_options=x_content_type_options,
|
||||
x_frame_options=x_frame_options,
|
||||
referrer_policy=referrer_policy,
|
||||
permissions_policy=permissions_policy,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_configured_headers(
|
||||
cls,
|
||||
*,
|
||||
x_content_type_options: str,
|
||||
x_frame_options: str,
|
||||
referrer_policy: str,
|
||||
permissions_policy: str,
|
||||
) -> tuple[tuple[bytes, bytes, bytes], ...]:
|
||||
configured: list[tuple[bytes, bytes, bytes]] = []
|
||||
for header_name, value in (
|
||||
(cls._X_CONTENT_TYPE_OPTIONS, x_content_type_options),
|
||||
(cls._X_FRAME_OPTIONS, x_frame_options),
|
||||
(cls._REFERRER_POLICY, referrer_policy),
|
||||
(cls._PERMISSIONS_POLICY, permissions_policy),
|
||||
):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
configured.append(
|
||||
(
|
||||
header_name.lower(),
|
||||
header_name,
|
||||
normalized.encode("latin-1"),
|
||||
)
|
||||
)
|
||||
return tuple(configured)
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""Append configured security headers unless already present."""
|
||||
if scope["type"] != "http" or not self._configured_headers:
|
||||
await self._app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_security_headers(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
# Starlette uses `list[tuple[bytes, bytes]]` for raw headers.
|
||||
headers: list[tuple[bytes, bytes]] = message.setdefault("headers", [])
|
||||
existing = {key.lower() for key, _ in headers}
|
||||
for key_lower, key, value in self._configured_headers:
|
||||
if key_lower not in existing:
|
||||
headers.append((key, value))
|
||||
existing.add(key_lower)
|
||||
await send(message)
|
||||
|
||||
await self._app(scope, receive, send_with_security_headers)
|
||||
Reference in New Issue
Block a user